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 = deadline_after(options.timeout, "PTY read_until")?;
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 = deadline_after(timeout, "PTY drain_remaining")?;
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 deadline_after(timeout: Duration, operation: &str) -> io::Result<Instant> {
792    Instant::now().checked_add(timeout).ok_or_else(|| {
793        io::Error::new(
794            io::ErrorKind::InvalidInput,
795            format!("{operation}: timeout is too large"),
796        )
797    })
798}
799
800pub(crate) fn detach_join(handle: thread::JoinHandle<()>, thread_name: &str) {
801    let _ = thread::Builder::new()
802        .name(thread_name.to_string())
803        .spawn(move || {
804            let _ = handle.join();
805        });
806}
807
808/// Assert that terminal cleanup sequences were emitted.
809pub fn assert_terminal_restored(
810    output: &[u8],
811    expectations: &CleanupExpectations,
812) -> Result<(), String> {
813    let mut failures = Vec::new();
814
815    if expectations.sgr_reset && !contains_any(output, SGR_RESET_SEQS) {
816        failures.push("Missing SGR reset (CSI 0 m)");
817    }
818    if expectations.show_cursor && !contains_any(output, CURSOR_SHOW_SEQS) {
819        failures.push("Missing cursor show (CSI ? 25 h)");
820    }
821    if expectations.alt_screen && !contains_any(output, ALT_SCREEN_EXIT_SEQS) {
822        failures.push("Missing alt-screen exit (CSI ? 1049 l)");
823    }
824    if expectations.mouse && !contains_any(output, MOUSE_DISABLE_SEQS) {
825        failures.push("Missing mouse disable (CSI ? 1000... l)");
826    }
827    if expectations.bracketed_paste && !contains_any(output, BRACKETED_PASTE_DISABLE_SEQS) {
828        failures.push("Missing bracketed paste disable (CSI ? 2004 l)");
829    }
830    if expectations.focus_events && !contains_any(output, FOCUS_DISABLE_SEQS) {
831        failures.push("Missing focus disable (CSI ? 1004 l)");
832    }
833    if expectations.kitty_keyboard && !contains_any(output, KITTY_DISABLE_SEQS) {
834        failures.push("Missing kitty keyboard disable (CSI < u)");
835    }
836
837    if failures.is_empty() {
838        log_event(true, "PTY_TEST_PASS", "terminal cleanup sequences verified");
839        return Ok(());
840    }
841
842    for failure in &failures {
843        log_event(true, "PTY_FAILURE_REASON", *failure);
844    }
845
846    log_event(true, "PTY_OUTPUT_DUMP", "hex:");
847    for line in hex_dump(output, 4096).lines() {
848        log_event(true, "PTY_OUTPUT_DUMP", line);
849    }
850
851    log_event(true, "PTY_OUTPUT_DUMP", "printable:");
852    for line in printable_dump(output, 4096).lines() {
853        log_event(true, "PTY_OUTPUT_DUMP", line);
854    }
855
856    Err(failures.join("; "))
857}
858
859fn log_event(enabled: bool, event: &str, detail: impl fmt::Display) {
860    if !enabled {
861        return;
862    }
863
864    let timestamp = timestamp_rfc3339();
865    eprintln!("[{}] {}: {}", timestamp, event, detail);
866}
867
868fn timestamp_rfc3339() -> String {
869    time::OffsetDateTime::now_utc()
870        .format(&time::format_description::well_known::Rfc3339)
871        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
872}
873
874fn hex_preview(bytes: &[u8], limit: usize) -> String {
875    let mut out = String::new();
876    for b in bytes.iter().take(limit) {
877        out.push_str(&format!("{:02x}", b));
878    }
879    if bytes.len() > limit {
880        out.push_str("..");
881    }
882    out
883}
884
885fn hex_dump(bytes: &[u8], limit: usize) -> String {
886    let mut out = String::new();
887    let slice = bytes.get(0..limit).unwrap_or(bytes);
888
889    for (row, chunk) in slice.chunks(16).enumerate() {
890        let offset = row * 16;
891        out.push_str(&format!("{:04x}: ", offset));
892        for b in chunk {
893            out.push_str(&format!("{:02x} ", b));
894        }
895        out.push('\n');
896    }
897
898    if bytes.len() > limit {
899        out.push_str("... (truncated)\n");
900    }
901
902    out
903}
904
905fn printable_dump(bytes: &[u8], limit: usize) -> String {
906    let mut out = String::new();
907    let slice = bytes.get(0..limit).unwrap_or(bytes);
908
909    for (row, chunk) in slice.chunks(16).enumerate() {
910        let offset = row * 16;
911        out.push_str(&format!("{:04x}: ", offset));
912        for b in chunk {
913            let ch = if b.is_ascii_graphic() || *b == b' ' {
914                *b as char
915            } else {
916                '.'
917            };
918            out.push(ch);
919        }
920        out.push('\n');
921    }
922
923    if bytes.len() > limit {
924        out.push_str("... (truncated)\n");
925    }
926
927    out
928}
929
930fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
931    if needle.is_empty() {
932        return Some(0);
933    }
934    haystack
935        .windows(needle.len())
936        .position(|window| window == needle)
937}
938
939fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
940    needles
941        .iter()
942        .any(|needle| find_subsequence(haystack, needle).is_some())
943}
944
945fn portable_pty_error<E: fmt::Display>(err: E) -> io::Error {
946    io::Error::other(err.to_string())
947}
948
949/// Check if an I/O error is transient and worth retrying.
950fn is_transient_error(err: &io::Error) -> bool {
951    matches!(
952        err.kind(),
953        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted | io::ErrorKind::TimedOut
954    )
955}
956
957const SGR_RESET_SEQS: &[&[u8]] = &[b"\x1b[0m", b"\x1b[m"];
958const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
959const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
960const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
961    b"\x1b[?1000l\x1b[?1002l\x1b[?1006l",
962    b"\x1b[?1002l\x1b[?1006l",
963    b"\x1b[?1000;1002;1006l",
964    b"\x1b[?1000;1002l",
965    b"\x1b[?1000l",
966    b"\x1b[?1002l",
967    b"\x1b[?1006l",
968];
969const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
970const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
971const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976
977    #[test]
978    fn normalize_line_input_appends_carriage_return() {
979        assert_eq!(normalize_line_input(b"echo hi"), b"echo hi\r\n");
980    }
981
982    #[test]
983    fn normalize_line_input_replaces_trailing_line_feed() {
984        assert_eq!(normalize_line_input(b"echo hi\n"), b"echo hi\r\n");
985    }
986
987    #[test]
988    fn normalize_line_input_preserves_existing_carriage_return() {
989        assert_eq!(normalize_line_input(b"echo hi\r"), b"echo hi\r\n");
990    }
991    #[cfg(unix)]
992    use ftui_core::terminal_session::{TerminalSession, best_effort_cleanup_for_exit};
993
994    #[test]
995    fn cleanup_expectations_match_sequences() {
996        let output =
997            b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000;1002;1006l\x1b[?2004l\x1b[?1004l\x1b[<u";
998        assert_terminal_restored(output, &CleanupExpectations::strict())
999            .expect("terminal cleanup assertions failed");
1000    }
1001
1002    #[test]
1003    #[should_panic]
1004    fn cleanup_expectations_fail_when_missing() {
1005        let output = b"\x1b[?25h";
1006        assert_terminal_restored(output, &CleanupExpectations::strict())
1007            .expect("terminal cleanup assertions failed");
1008    }
1009
1010    #[cfg(unix)]
1011    #[test]
1012    fn spawn_command_captures_output() {
1013        let config = PtyConfig::default().logging(false);
1014
1015        let mut cmd = CommandBuilder::new("sh");
1016        cmd.args(["-c", "printf hello-pty"]);
1017
1018        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1019
1020        let _status = session.wait().expect("wait should succeed");
1021        // Use read_until with a timeout to avoid a race condition:
1022        // after wait() returns, the reader thread may not have drained
1023        // all PTY output yet. A non-blocking read_output() can miss data.
1024        let output = session
1025            .read_until(b"hello-pty", Duration::from_secs(5))
1026            .expect("expected PTY output to contain test string");
1027        assert!(
1028            output
1029                .windows(b"hello-pty".len())
1030                .any(|w| w == b"hello-pty"),
1031            "expected PTY output to contain test string"
1032        );
1033    }
1034
1035    #[cfg(unix)]
1036    #[test]
1037    fn read_until_with_options_min_bytes() {
1038        let config = PtyConfig::default().logging(false);
1039
1040        let mut cmd = CommandBuilder::new("sh");
1041        cmd.args(["-c", "printf 'short'; sleep 0.05; printf 'longer-output'"]);
1042
1043        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1044
1045        // Wait for at least 10 bytes before matching "output"
1046        let options = ReadUntilOptions::with_timeout(Duration::from_secs(5)).min_bytes(10);
1047
1048        let output = session
1049            .read_until_with_options(b"output", options)
1050            .expect("expected to find pattern with min_bytes");
1051
1052        assert!(
1053            output.len() >= 10,
1054            "expected at least 10 bytes, got {}",
1055            output.len()
1056        );
1057        assert!(
1058            output.windows(b"output".len()).any(|w| w == b"output"),
1059            "expected pattern 'output' in captured data"
1060        );
1061    }
1062
1063    #[cfg(unix)]
1064    #[test]
1065    fn read_until_with_options_retries_on_timeout_then_succeeds() {
1066        let config = PtyConfig::default().logging(false);
1067
1068        let mut cmd = CommandBuilder::new("sh");
1069        cmd.args(["-c", "sleep 0.1; printf done"]);
1070
1071        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1072
1073        // Short initial timeout but with retries
1074        let options = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1075            .retries(3)
1076            .retry_delay(Duration::from_millis(50));
1077
1078        let output = session
1079            .read_until_with_options(b"done", options)
1080            .expect("should succeed with retries");
1081
1082        assert!(
1083            output.windows(b"done".len()).any(|w| w == b"done"),
1084            "expected 'done' in output"
1085        );
1086    }
1087
1088    // --- Deterministic capture ordering tests ---
1089
1090    #[cfg(unix)]
1091    #[test]
1092    fn large_output_fully_captured() {
1093        let config = PtyConfig::default().logging(false);
1094
1095        // Generate 64KB of output to ensure large buffers are handled
1096        let mut cmd = CommandBuilder::new("sh");
1097        cmd.args(["-c", "dd if=/dev/zero bs=1024 count=64 2>/dev/null | od -v"]);
1098
1099        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1100
1101        let _status = session
1102            .wait_and_drain(Duration::from_secs(5))
1103            .expect("wait_and_drain");
1104
1105        // Should have captured substantial output (od output is larger than input)
1106        let output = session.output();
1107        assert!(
1108            output.len() > 50_000,
1109            "expected >50KB of output, got {} bytes",
1110            output.len()
1111        );
1112    }
1113
1114    #[cfg(unix)]
1115    #[test]
1116    fn late_output_after_exit_captured() {
1117        let config = PtyConfig::default().logging(false);
1118
1119        // Script that writes output slowly, including after main processing
1120        let mut cmd = CommandBuilder::new("sh");
1121        cmd.args([
1122            "-c",
1123            "printf 'start\\n'; sleep 0.05; printf 'middle\\n'; sleep 0.05; printf 'end\\n'",
1124        ]);
1125
1126        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1127
1128        // Wait for process to exit
1129        let _status = session.wait().expect("wait should succeed");
1130
1131        // Now drain remaining output
1132        let _drained = session
1133            .drain_remaining(Duration::from_secs(2))
1134            .expect("drain_remaining should succeed");
1135
1136        let output = session.output();
1137        let output_str = String::from_utf8_lossy(output);
1138
1139        // Verify all output was captured including late writes
1140        assert!(
1141            output_str.contains("start"),
1142            "missing 'start' in output: {output_str:?}"
1143        );
1144        assert!(
1145            output_str.contains("middle"),
1146            "missing 'middle' in output: {output_str:?}"
1147        );
1148        assert!(
1149            output_str.contains("end"),
1150            "missing 'end' in output: {output_str:?}"
1151        );
1152
1153        // Verify deterministic ordering (start before middle before end)
1154        let start_pos = output_str.find("start").unwrap();
1155        let middle_pos = output_str.find("middle").unwrap();
1156        let end_pos = output_str.find("end").unwrap();
1157        assert!(
1158            start_pos < middle_pos && middle_pos < end_pos,
1159            "output not in expected order: start={start_pos}, middle={middle_pos}, end={end_pos}"
1160        );
1161
1162        // Drain should return 0 on second call (all captured)
1163        let drained_again = session
1164            .drain_remaining(Duration::from_millis(100))
1165            .expect("second drain should succeed");
1166        assert_eq!(drained_again, 0, "second drain should return 0");
1167    }
1168
1169    #[cfg(unix)]
1170    #[test]
1171    fn wait_and_drain_captures_all() {
1172        let config = PtyConfig::default().logging(false);
1173
1174        let mut cmd = CommandBuilder::new("sh");
1175        cmd.args([
1176            "-c",
1177            "for i in 1 2 3 4 5; do printf \"line$i\\n\"; sleep 0.02; done",
1178        ]);
1179
1180        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1181
1182        // Use wait_and_drain for deterministic capture
1183        let status = session
1184            .wait_and_drain(Duration::from_secs(2))
1185            .expect("wait_and_drain should succeed");
1186
1187        assert!(status.success(), "child should succeed");
1188
1189        let output = session.output();
1190        let output_str = String::from_utf8_lossy(output);
1191
1192        // Verify all 5 lines were captured
1193        for i in 1..=5 {
1194            assert!(
1195                output_str.contains(&format!("line{i}")),
1196                "missing 'line{i}' in output: {output_str:?}"
1197            );
1198        }
1199    }
1200
1201    #[cfg(unix)]
1202    #[test]
1203    fn wait_and_drain_large_output_ordered() {
1204        let config = PtyConfig::default().logging(false);
1205
1206        let mut cmd = CommandBuilder::new("sh");
1207        cmd.args([
1208            "-c",
1209            "i=1; while [ $i -le 1200 ]; do printf \"line%04d\\n\" $i; i=$((i+1)); done",
1210        ]);
1211
1212        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1213
1214        let status = session
1215            .wait_and_drain(Duration::from_secs(3))
1216            .expect("wait_and_drain should succeed");
1217
1218        assert!(status.success(), "child should succeed");
1219
1220        let output = session.output();
1221        let output_str = String::from_utf8_lossy(output);
1222        let lines: Vec<&str> = output_str.lines().collect();
1223
1224        assert_eq!(
1225            lines.len(),
1226            1200,
1227            "expected 1200 lines, got {}",
1228            lines.len()
1229        );
1230        assert_eq!(lines.first().copied(), Some("line0001"));
1231        assert_eq!(lines.last().copied(), Some("line1200"));
1232    }
1233
1234    #[cfg(unix)]
1235    #[test]
1236    fn drain_remaining_respects_eof() {
1237        let config = PtyConfig::default().logging(false);
1238
1239        let mut cmd = CommandBuilder::new("sh");
1240        cmd.args(["-c", "printf 'quick'"]);
1241
1242        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1243
1244        // Wait for exit and drain
1245        let _ = session
1246            .wait_and_drain(Duration::from_secs(2))
1247            .expect("wait_and_drain");
1248
1249        // Session should now be at EOF
1250        assert!(session.eof, "should be at EOF after wait_and_drain");
1251
1252        // Further drain attempts should return 0 immediately
1253        let result = session
1254            .drain_remaining(Duration::from_secs(1))
1255            .expect("drain");
1256        assert_eq!(result, 0, "drain after EOF should return 0");
1257    }
1258
1259    #[cfg(unix)]
1260    #[test]
1261    fn pty_terminal_session_cleanup() {
1262        let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1263        cmd.args([
1264            "--exact",
1265            "tests::pty_terminal_session_cleanup_child",
1266            "--nocapture",
1267        ]);
1268        cmd.env("FTUI_PTY_CHILD", "1");
1269        cmd.env("FTUI_TEST_PROFILE", "modern");
1270        cmd.env("TERM", "xterm-256color");
1271
1272        let config = PtyConfig::default()
1273            .with_test_name("terminal_session_cleanup")
1274            .logging(false);
1275        let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1276
1277        let status = session.wait().expect("wait for child");
1278        assert!(status.success(), "child test failed: {:?}", status);
1279
1280        let _ = session
1281            .read_until(b"\x1b[?25h", Duration::from_secs(5))
1282            .expect("expected cursor show sequence");
1283        let _ = session
1284            .drain_remaining(Duration::from_secs(1))
1285            .expect("drain remaining");
1286        let output = session.output();
1287
1288        let options = SessionOptions {
1289            alternate_screen: true,
1290            mouse_capture: true,
1291            bracketed_paste: true,
1292            focus_events: true,
1293            kitty_keyboard: true,
1294            intercept_signals: true,
1295        };
1296        let expectations = CleanupExpectations::for_session(&options);
1297        assert_terminal_restored(output, &expectations)
1298            .expect("terminal cleanup assertions failed");
1299    }
1300
1301    #[cfg(unix)]
1302    #[test]
1303    fn pty_terminal_session_cleanup_child() {
1304        if std::env::var("FTUI_PTY_CHILD").as_deref() != Ok("1") {
1305            return;
1306        }
1307
1308        let options = SessionOptions {
1309            alternate_screen: true,
1310            mouse_capture: true,
1311            bracketed_paste: true,
1312            focus_events: true,
1313            kitty_keyboard: true,
1314            intercept_signals: true,
1315        };
1316
1317        let _session = TerminalSession::new(options).expect("TerminalSession::new");
1318    }
1319
1320    #[cfg(unix)]
1321    #[test]
1322    fn pty_terminal_session_cleanup_on_panic() {
1323        let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1324        cmd.args([
1325            "--exact",
1326            "tests::pty_terminal_session_cleanup_panic_child",
1327            "--nocapture",
1328        ]);
1329        cmd.env("FTUI_PTY_PANIC_CHILD", "1");
1330        cmd.env("FTUI_TEST_PROFILE", "modern");
1331        cmd.env("TERM", "xterm-256color");
1332
1333        let config = PtyConfig::default()
1334            .with_test_name("terminal_session_cleanup_panic")
1335            .logging(false);
1336        let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1337
1338        let status = session.wait().expect("wait for child");
1339        assert!(
1340            !status.success(),
1341            "panic child should exit with failure status"
1342        );
1343
1344        let _ = session
1345            .read_until(b"\x1b[?25h", Duration::from_secs(5))
1346            .expect("expected cursor show sequence");
1347        let _ = session
1348            .drain_remaining(Duration::from_secs(1))
1349            .expect("drain remaining");
1350        let output = session.output();
1351
1352        let options = SessionOptions {
1353            alternate_screen: true,
1354            mouse_capture: true,
1355            bracketed_paste: true,
1356            focus_events: true,
1357            kitty_keyboard: true,
1358            intercept_signals: true,
1359        };
1360        let expectations = CleanupExpectations::for_session(&options);
1361        assert_terminal_restored(output, &expectations)
1362            .expect("terminal cleanup assertions failed");
1363    }
1364
1365    #[cfg(unix)]
1366    #[test]
1367    fn pty_terminal_session_cleanup_panic_child() {
1368        if std::env::var("FTUI_PTY_PANIC_CHILD").as_deref() != Ok("1") {
1369            return;
1370        }
1371
1372        let options = SessionOptions {
1373            alternate_screen: true,
1374            mouse_capture: true,
1375            bracketed_paste: true,
1376            focus_events: true,
1377            kitty_keyboard: true,
1378            intercept_signals: true,
1379        };
1380
1381        let _session = TerminalSession::new(options).expect("TerminalSession::new");
1382        std::panic::panic_any("intentional panic to verify cleanup on unwind");
1383    }
1384
1385    #[cfg(unix)]
1386    #[test]
1387    fn pty_terminal_session_cleanup_on_exit() {
1388        let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1389        cmd.args([
1390            "--exact",
1391            "tests::pty_terminal_session_cleanup_exit_child",
1392            "--nocapture",
1393        ]);
1394        cmd.env("FTUI_PTY_EXIT_CHILD", "1");
1395        cmd.env("FTUI_TEST_PROFILE", "modern");
1396        cmd.env("TERM", "xterm-256color");
1397
1398        let config = PtyConfig::default()
1399            .with_test_name("terminal_session_cleanup_exit")
1400            .logging(false);
1401        let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1402
1403        let status = session.wait().expect("wait for child");
1404        assert!(status.success(), "exit child should succeed: {:?}", status);
1405
1406        let _ = session
1407            .read_until(b"\x1b[?25h", Duration::from_secs(5))
1408            .expect("expected cursor show sequence");
1409        let _ = session
1410            .drain_remaining(Duration::from_secs(1))
1411            .expect("drain remaining");
1412        let output = session.output();
1413
1414        let options = SessionOptions {
1415            alternate_screen: true,
1416            mouse_capture: true,
1417            bracketed_paste: true,
1418            focus_events: true,
1419            kitty_keyboard: true,
1420            intercept_signals: true,
1421        };
1422        let expectations = CleanupExpectations::for_session(&options);
1423        assert_terminal_restored(output, &expectations)
1424            .expect("terminal cleanup assertions failed");
1425    }
1426
1427    #[cfg(unix)]
1428    #[test]
1429    fn pty_terminal_session_cleanup_exit_child() {
1430        if std::env::var("FTUI_PTY_EXIT_CHILD").as_deref() != Ok("1") {
1431            return;
1432        }
1433
1434        let options = SessionOptions {
1435            alternate_screen: true,
1436            mouse_capture: true,
1437            bracketed_paste: true,
1438            focus_events: true,
1439            kitty_keyboard: true,
1440            intercept_signals: true,
1441        };
1442
1443        let _session = TerminalSession::new(options).expect("TerminalSession::new");
1444        best_effort_cleanup_for_exit();
1445        std::process::exit(0);
1446    }
1447
1448    // --- find_subsequence tests ---
1449
1450    #[test]
1451    fn find_subsequence_empty_needle() {
1452        assert_eq!(find_subsequence(b"anything", b""), Some(0));
1453    }
1454
1455    #[test]
1456    fn find_subsequence_empty_haystack() {
1457        assert_eq!(find_subsequence(b"", b"x"), None);
1458    }
1459
1460    #[test]
1461    fn find_subsequence_found_at_start() {
1462        assert_eq!(find_subsequence(b"hello world", b"hello"), Some(0));
1463    }
1464
1465    #[test]
1466    fn find_subsequence_found_in_middle() {
1467        assert_eq!(find_subsequence(b"hello world", b"o w"), Some(4));
1468    }
1469
1470    #[test]
1471    fn find_subsequence_found_at_end() {
1472        assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
1473    }
1474
1475    #[test]
1476    fn find_subsequence_not_found() {
1477        assert_eq!(find_subsequence(b"hello world", b"xyz"), None);
1478    }
1479
1480    #[test]
1481    fn find_subsequence_needle_longer_than_haystack() {
1482        assert_eq!(find_subsequence(b"ab", b"abcdef"), None);
1483    }
1484
1485    #[test]
1486    fn find_subsequence_exact_match() {
1487        assert_eq!(find_subsequence(b"abc", b"abc"), Some(0));
1488    }
1489
1490    // --- contains_any tests ---
1491
1492    #[test]
1493    fn contains_any_finds_first_match() {
1494        assert!(contains_any(b"\x1b[0m test", &[b"\x1b[0m", b"\x1b[m"]));
1495    }
1496
1497    #[test]
1498    fn contains_any_finds_second_match() {
1499        assert!(contains_any(b"\x1b[m test", &[b"\x1b[0m", b"\x1b[m"]));
1500    }
1501
1502    #[test]
1503    fn contains_any_no_match() {
1504        assert!(!contains_any(b"plain text", &[b"\x1b[0m", b"\x1b[m"]));
1505    }
1506
1507    #[test]
1508    fn contains_any_empty_needles() {
1509        assert!(!contains_any(b"test", &[]));
1510    }
1511
1512    // --- hex_preview tests ---
1513
1514    #[test]
1515    fn hex_preview_basic() {
1516        let result = hex_preview(&[0x41, 0x42, 0x43], 10);
1517        assert_eq!(result, "414243");
1518    }
1519
1520    #[test]
1521    fn hex_preview_truncated() {
1522        let result = hex_preview(&[0x00, 0x01, 0x02, 0x03, 0x04], 3);
1523        assert_eq!(result, "000102..");
1524    }
1525
1526    #[test]
1527    fn hex_preview_empty() {
1528        assert_eq!(hex_preview(&[], 10), "");
1529    }
1530
1531    // --- hex_dump tests ---
1532
1533    #[test]
1534    fn hex_dump_single_row() {
1535        let result = hex_dump(&[0x41, 0x42], 100);
1536        assert!(result.starts_with("0000: "));
1537        assert!(result.contains("41 42"));
1538    }
1539
1540    #[test]
1541    fn hex_dump_multi_row() {
1542        let data: Vec<u8> = (0..20).collect();
1543        let result = hex_dump(&data, 100);
1544        assert!(result.contains("0000: "));
1545        assert!(result.contains("0010: ")); // second row at offset 16
1546    }
1547
1548    #[test]
1549    fn hex_dump_truncated() {
1550        let data: Vec<u8> = (0..100).collect();
1551        let result = hex_dump(&data, 32);
1552        assert!(result.contains("(truncated)"));
1553    }
1554
1555    #[test]
1556    fn hex_dump_empty() {
1557        let result = hex_dump(&[], 100);
1558        assert!(result.is_empty());
1559    }
1560
1561    // --- printable_dump tests ---
1562
1563    #[test]
1564    fn printable_dump_ascii() {
1565        let result = printable_dump(b"Hello", 100);
1566        assert!(result.contains("Hello"));
1567    }
1568
1569    #[test]
1570    fn printable_dump_replaces_control_chars() {
1571        let result = printable_dump(&[0x01, 0x02, 0x1B], 100);
1572        // Control chars should be replaced with '.'
1573        assert!(result.contains("..."));
1574    }
1575
1576    #[test]
1577    fn printable_dump_truncated() {
1578        let data: Vec<u8> = (0..100).collect();
1579        let result = printable_dump(&data, 32);
1580        assert!(result.contains("(truncated)"));
1581    }
1582
1583    // --- PtyConfig builder tests ---
1584
1585    #[test]
1586    fn pty_config_defaults() {
1587        let config = PtyConfig::default();
1588        assert_eq!(config.cols, 80);
1589        assert_eq!(config.rows, 24);
1590        assert_eq!(config.term.as_deref(), Some("xterm-256color"));
1591        assert!(config.env.is_empty());
1592        assert!(config.test_name.is_none());
1593        assert!(config.log_events);
1594    }
1595
1596    #[test]
1597    fn pty_config_with_size() {
1598        let config = PtyConfig::default().with_size(120, 40);
1599        assert_eq!(config.cols, 120);
1600        assert_eq!(config.rows, 40);
1601    }
1602
1603    #[test]
1604    fn pty_config_with_term() {
1605        let config = PtyConfig::default().with_term("dumb");
1606        assert_eq!(config.term.as_deref(), Some("dumb"));
1607    }
1608
1609    #[test]
1610    fn pty_config_with_env() {
1611        let config = PtyConfig::default()
1612            .with_env("FOO", "bar")
1613            .with_env("BAZ", "qux");
1614        assert_eq!(config.env.len(), 2);
1615        assert_eq!(config.env[0], ("FOO".to_string(), "bar".to_string()));
1616        assert_eq!(config.env[1], ("BAZ".to_string(), "qux".to_string()));
1617    }
1618
1619    #[test]
1620    fn pty_config_with_test_name() {
1621        let config = PtyConfig::default().with_test_name("my_test");
1622        assert_eq!(config.test_name.as_deref(), Some("my_test"));
1623    }
1624
1625    #[test]
1626    fn pty_config_logging_disabled() {
1627        let config = PtyConfig::default().logging(false);
1628        assert!(!config.log_events);
1629    }
1630
1631    #[test]
1632    fn pty_config_builder_chaining() {
1633        let config = PtyConfig::default()
1634            .with_size(132, 50)
1635            .with_term("xterm")
1636            .with_env("KEY", "val")
1637            .with_test_name("chain_test")
1638            .logging(false);
1639        assert_eq!(config.cols, 132);
1640        assert_eq!(config.rows, 50);
1641        assert_eq!(config.term.as_deref(), Some("xterm"));
1642        assert_eq!(config.env.len(), 1);
1643        assert_eq!(config.test_name.as_deref(), Some("chain_test"));
1644        assert!(!config.log_events);
1645    }
1646
1647    // --- ReadUntilOptions tests ---
1648
1649    #[test]
1650    fn read_until_options_defaults() {
1651        let opts = ReadUntilOptions::default();
1652        assert_eq!(opts.timeout, Duration::from_secs(5));
1653        assert_eq!(opts.max_retries, 0);
1654        assert_eq!(opts.retry_delay, Duration::from_millis(100));
1655        assert_eq!(opts.min_bytes, 0);
1656    }
1657
1658    #[test]
1659    fn read_until_options_with_timeout() {
1660        let opts = ReadUntilOptions::with_timeout(Duration::from_secs(10));
1661        assert_eq!(opts.timeout, Duration::from_secs(10));
1662        assert_eq!(opts.max_retries, 0); // other fields unchanged
1663    }
1664
1665    #[test]
1666    fn read_until_options_builder_chaining() {
1667        let opts = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1668            .retries(5)
1669            .retry_delay(Duration::from_millis(50))
1670            .min_bytes(100);
1671        assert_eq!(opts.timeout, Duration::from_secs(3));
1672        assert_eq!(opts.max_retries, 5);
1673        assert_eq!(opts.retry_delay, Duration::from_millis(50));
1674        assert_eq!(opts.min_bytes, 100);
1675    }
1676
1677    // --- is_transient_error tests ---
1678
1679    #[test]
1680    fn is_transient_error_would_block() {
1681        let err = io::Error::new(io::ErrorKind::WouldBlock, "test");
1682        assert!(is_transient_error(&err));
1683    }
1684
1685    #[test]
1686    fn is_transient_error_interrupted() {
1687        let err = io::Error::new(io::ErrorKind::Interrupted, "test");
1688        assert!(is_transient_error(&err));
1689    }
1690
1691    #[test]
1692    fn is_transient_error_timed_out() {
1693        let err = io::Error::new(io::ErrorKind::TimedOut, "test");
1694        assert!(is_transient_error(&err));
1695    }
1696
1697    #[test]
1698    fn is_transient_error_not_found() {
1699        let err = io::Error::new(io::ErrorKind::NotFound, "test");
1700        assert!(!is_transient_error(&err));
1701    }
1702
1703    #[test]
1704    fn is_transient_error_connection_refused() {
1705        let err = io::Error::new(io::ErrorKind::ConnectionRefused, "test");
1706        assert!(!is_transient_error(&err));
1707    }
1708
1709    // --- CleanupExpectations tests ---
1710
1711    #[test]
1712    fn cleanup_strict_all_true() {
1713        let strict = CleanupExpectations::strict();
1714        assert!(strict.sgr_reset);
1715        assert!(strict.show_cursor);
1716        assert!(strict.alt_screen);
1717        assert!(strict.mouse);
1718        assert!(strict.bracketed_paste);
1719        assert!(strict.focus_events);
1720        assert!(strict.kitty_keyboard);
1721    }
1722
1723    #[test]
1724    fn cleanup_for_session_matches_options() {
1725        let options = SessionOptions {
1726            alternate_screen: true,
1727            mouse_capture: false,
1728            bracketed_paste: true,
1729            focus_events: false,
1730            kitty_keyboard: true,
1731            intercept_signals: true,
1732        };
1733        let expectations = CleanupExpectations::for_session(&options);
1734        assert!(!expectations.sgr_reset); // always false for for_session
1735        assert!(expectations.show_cursor); // always true
1736        assert!(expectations.alt_screen);
1737        assert!(!expectations.mouse);
1738        assert!(expectations.bracketed_paste);
1739        assert!(!expectations.focus_events);
1740        assert!(expectations.kitty_keyboard);
1741    }
1742
1743    #[test]
1744    fn cleanup_for_session_all_disabled() {
1745        let options = SessionOptions {
1746            alternate_screen: false,
1747            mouse_capture: false,
1748            bracketed_paste: false,
1749            focus_events: false,
1750            kitty_keyboard: false,
1751            intercept_signals: true,
1752        };
1753        let expectations = CleanupExpectations::for_session(&options);
1754        assert!(expectations.show_cursor); // still true
1755        assert!(!expectations.alt_screen);
1756        assert!(!expectations.mouse);
1757        assert!(!expectations.bracketed_paste);
1758        assert!(!expectations.focus_events);
1759        assert!(!expectations.kitty_keyboard);
1760    }
1761
1762    // --- assert_terminal_restored edge cases ---
1763
1764    #[test]
1765    fn assert_restored_with_alt_sequence_variants() {
1766        // Both alt-screen exit sequences should be accepted
1767        let output1 = b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1768        assert_terminal_restored(output1, &CleanupExpectations::strict())
1769            .expect("terminal cleanup assertions failed");
1770
1771        let output2 = b"\x1b[0m\x1b[?25h\x1b[?1047l\x1b[?1000;1002l\x1b[?2004l\x1b[?1004l\x1b[<u";
1772        assert_terminal_restored(output2, &CleanupExpectations::strict())
1773            .expect("terminal cleanup assertions failed");
1774    }
1775
1776    #[test]
1777    fn assert_restored_sgr_reset_variant() {
1778        // Both \x1b[0m and \x1b[m should be accepted for sgr_reset
1779        let output = b"\x1b[m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1780        assert_terminal_restored(output, &CleanupExpectations::strict())
1781            .expect("terminal cleanup assertions failed");
1782    }
1783
1784    #[test]
1785    fn assert_restored_partial_expectations() {
1786        // Only cursor show required — should pass with just that sequence
1787        let expectations = CleanupExpectations {
1788            sgr_reset: false,
1789            show_cursor: true,
1790            alt_screen: false,
1791            mouse: false,
1792            bracketed_paste: false,
1793            focus_events: false,
1794            kitty_keyboard: false,
1795        };
1796        assert_terminal_restored(b"\x1b[?25h", &expectations)
1797            .expect("terminal cleanup assertions failed");
1798    }
1799
1800    // --- sequence constant tests ---
1801
1802    #[test]
1803    fn sequence_constants_are_nonempty() {
1804        assert!(!SGR_RESET_SEQS.is_empty());
1805        assert!(!CURSOR_SHOW_SEQS.is_empty());
1806        assert!(!ALT_SCREEN_EXIT_SEQS.is_empty());
1807        assert!(!MOUSE_DISABLE_SEQS.is_empty());
1808        assert!(!BRACKETED_PASTE_DISABLE_SEQS.is_empty());
1809        assert!(!FOCUS_DISABLE_SEQS.is_empty());
1810        assert!(!KITTY_DISABLE_SEQS.is_empty());
1811    }
1812
1813    #[test]
1814    fn deadline_after_rejects_unrepresentable_timeout() {
1815        let error = deadline_after(Duration::MAX, "test operation")
1816            .expect_err("oversized timeouts should not panic while building a deadline");
1817
1818        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
1819        assert!(
1820            error.to_string().contains("test operation"),
1821            "error should name the operation that rejected the timeout"
1822        );
1823    }
1824
1825    #[test]
1826    fn deadline_after_accepts_normal_timeout() {
1827        let before = Instant::now();
1828        let deadline = deadline_after(Duration::from_secs(60), "test operation")
1829            .expect("normal timeout should produce a deadline");
1830
1831        assert!(deadline >= before);
1832    }
1833
1834    #[cfg(unix)]
1835    #[test]
1836    fn drop_does_not_block_when_background_process_keeps_pty_open() {
1837        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1838        let (done_tx, done_rx) = mpsc::channel();
1839        let drop_thread = thread::spawn(move || {
1840            let mut cmd = CommandBuilder::new(&shell);
1841            cmd.arg("-c");
1842            cmd.arg("sleep 1 >/dev/null 2>&1 &");
1843            let session =
1844                spawn_command(PtyConfig::default().logging(false), cmd).expect("spawn session");
1845            drop(session);
1846            done_tx.send(()).expect("signal drop completion");
1847        });
1848
1849        assert!(
1850            done_rx.recv_timeout(Duration::from_millis(400)).is_ok(),
1851            "PtySession drop should not wait for background descendants to close the PTY"
1852        );
1853        drop_thread.join().expect("drop thread join");
1854    }
1855
1856    #[cfg(unix)]
1857    #[test]
1858    fn send_input_times_out_when_child_does_not_drain_stdin() {
1859        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1860        let mut cmd = CommandBuilder::new(&shell);
1861        cmd.arg("-c");
1862        cmd.arg("sleep 5");
1863        let mut session = spawn_command(
1864            PtyConfig::default()
1865                .logging(false)
1866                .with_input_write_timeout(Duration::from_millis(100)),
1867            cmd,
1868        )
1869        .expect("spawn session");
1870
1871        let payload = vec![b'x'; 8 * 1024 * 1024];
1872        let start = Instant::now();
1873        let err = session
1874            .send_input(&payload)
1875            .expect_err("send_input should time out when the child never reads stdin");
1876        assert_eq!(err.kind(), io::ErrorKind::TimedOut);
1877        assert!(
1878            start.elapsed() < Duration::from_secs(2),
1879            "send_input should fail promptly instead of hanging"
1880        );
1881    }
1882}