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