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