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