1#![forbid(unsafe_code)]
2
3pub mod input_forwarding;
31
32pub mod pty_process;
34
35pub 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#[derive(Debug, Clone)]
49pub struct PtyConfig {
50 pub cols: u16,
52 pub rows: u16,
54 pub term: Option<String>,
56 pub env: Vec<(String, String)>,
58 pub test_name: Option<String>,
60 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 pub fn with_size(mut self, cols: u16, rows: u16) -> Self {
80 self.cols = cols;
81 self.rows = rows;
82 self
83 }
84
85 pub fn with_term(mut self, term: impl Into<String>) -> Self {
87 self.term = Some(term.into());
88 self
89 }
90
91 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 pub fn with_test_name(mut self, name: impl Into<String>) -> Self {
99 self.test_name = Some(name.into());
100 self
101 }
102
103 pub fn logging(mut self, enabled: bool) -> Self {
105 self.log_events = enabled;
106 self
107 }
108}
109
110#[derive(Debug, Clone)]
112pub struct ReadUntilOptions {
113 pub timeout: Duration,
115 pub max_retries: u32,
117 pub retry_delay: Duration,
119 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 pub fn with_timeout(timeout: Duration) -> Self {
137 Self {
138 timeout,
139 ..Default::default()
140 }
141 }
142
143 pub fn retries(mut self, count: u32) -> Self {
145 self.max_retries = count;
146 self
147 }
148
149 pub fn retry_delay(mut self, delay: Duration) -> Self {
151 self.retry_delay = delay;
152 self
153 }
154
155 pub fn min_bytes(mut self, bytes: usize) -> Self {
157 self.min_bytes = bytes;
158 self
159 }
160}
161
162#[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 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 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
209pub 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
231pub 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 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 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 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 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 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 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 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 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 pub fn wait(&mut self) -> io::Result<ExitStatus> {
438 self.child.wait()
439 }
440
441 pub fn output(&self) -> &[u8] {
443 &self.captured
444 }
445
446 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 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 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 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 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 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 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
630pub 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
771fn 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 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 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 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 #[cfg(unix)]
894 #[test]
895 fn large_output_fully_captured() {
896 let config = PtyConfig::default().logging(false);
897
898 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 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 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 let _status = session.wait().expect("wait should succeed");
933
934 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 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 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 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 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 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 let _ = session
1049 .wait_and_drain(Duration::from_secs(2))
1050 .expect("wait_and_drain");
1051
1052 assert!(session.eof, "should be at EOF after wait_and_drain");
1054
1055 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 #[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 #[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 #[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 #[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: ")); }
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 #[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 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 #[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 #[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); }
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 #[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 #[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); assert!(expectations.show_cursor); 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); 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 #[test]
1542 fn assert_restored_with_alt_sequence_variants() {
1543 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 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 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 #[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}