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
1089 let config = PtyConfig::default()
1090 .with_test_name("terminal_session_cleanup")
1091 .logging(false);
1092 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1093
1094 let status = session.wait().expect("wait for child");
1095 assert!(status.success(), "child test failed: {:?}", status);
1096
1097 let _ = session
1098 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1099 .expect("expected cursor show sequence");
1100 let _ = session
1101 .drain_remaining(Duration::from_secs(1))
1102 .expect("drain remaining");
1103 let output = session.output();
1104
1105 let options = SessionOptions {
1106 alternate_screen: true,
1107 mouse_capture: true,
1108 bracketed_paste: true,
1109 focus_events: true,
1110 kitty_keyboard: true,
1111 };
1112 let expectations = CleanupExpectations::for_session(&options);
1113 assert_terminal_restored(output, &expectations)
1114 .expect("terminal cleanup assertions failed");
1115 }
1116
1117 #[cfg(unix)]
1118 #[test]
1119 fn pty_terminal_session_cleanup_child() {
1120 if std::env::var("FTUI_PTY_CHILD").as_deref() != Ok("1") {
1121 return;
1122 }
1123
1124 let options = SessionOptions {
1125 alternate_screen: true,
1126 mouse_capture: true,
1127 bracketed_paste: true,
1128 focus_events: true,
1129 kitty_keyboard: true,
1130 };
1131
1132 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1133 }
1134
1135 #[cfg(unix)]
1136 #[test]
1137 fn pty_terminal_session_cleanup_on_panic() {
1138 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1139 cmd.args([
1140 "--exact",
1141 "tests::pty_terminal_session_cleanup_panic_child",
1142 "--nocapture",
1143 ]);
1144 cmd.env("FTUI_PTY_PANIC_CHILD", "1");
1145
1146 let config = PtyConfig::default()
1147 .with_test_name("terminal_session_cleanup_panic")
1148 .logging(false);
1149 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1150
1151 let status = session.wait().expect("wait for child");
1152 assert!(
1153 !status.success(),
1154 "panic child should exit with failure status"
1155 );
1156
1157 let _ = session
1158 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1159 .expect("expected cursor show sequence");
1160 let _ = session
1161 .drain_remaining(Duration::from_secs(1))
1162 .expect("drain remaining");
1163 let output = session.output();
1164
1165 let options = SessionOptions {
1166 alternate_screen: true,
1167 mouse_capture: true,
1168 bracketed_paste: true,
1169 focus_events: true,
1170 kitty_keyboard: true,
1171 };
1172 let expectations = CleanupExpectations::for_session(&options);
1173 assert_terminal_restored(output, &expectations)
1174 .expect("terminal cleanup assertions failed");
1175 }
1176
1177 #[cfg(unix)]
1178 #[test]
1179 fn pty_terminal_session_cleanup_panic_child() {
1180 if std::env::var("FTUI_PTY_PANIC_CHILD").as_deref() != Ok("1") {
1181 return;
1182 }
1183
1184 let options = SessionOptions {
1185 alternate_screen: true,
1186 mouse_capture: true,
1187 bracketed_paste: true,
1188 focus_events: true,
1189 kitty_keyboard: true,
1190 };
1191
1192 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1193 std::panic::panic_any("intentional panic to verify cleanup on unwind");
1194 }
1195
1196 #[cfg(unix)]
1197 #[test]
1198 fn pty_terminal_session_cleanup_on_exit() {
1199 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1200 cmd.args([
1201 "--exact",
1202 "tests::pty_terminal_session_cleanup_exit_child",
1203 "--nocapture",
1204 ]);
1205 cmd.env("FTUI_PTY_EXIT_CHILD", "1");
1206
1207 let config = PtyConfig::default()
1208 .with_test_name("terminal_session_cleanup_exit")
1209 .logging(false);
1210 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1211
1212 let status = session.wait().expect("wait for child");
1213 assert!(status.success(), "exit child should succeed: {:?}", status);
1214
1215 let _ = session
1216 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1217 .expect("expected cursor show sequence");
1218 let _ = session
1219 .drain_remaining(Duration::from_secs(1))
1220 .expect("drain remaining");
1221 let output = session.output();
1222
1223 let options = SessionOptions {
1224 alternate_screen: true,
1225 mouse_capture: true,
1226 bracketed_paste: true,
1227 focus_events: true,
1228 kitty_keyboard: true,
1229 };
1230 let expectations = CleanupExpectations::for_session(&options);
1231 assert_terminal_restored(output, &expectations)
1232 .expect("terminal cleanup assertions failed");
1233 }
1234
1235 #[cfg(unix)]
1236 #[test]
1237 fn pty_terminal_session_cleanup_exit_child() {
1238 if std::env::var("FTUI_PTY_EXIT_CHILD").as_deref() != Ok("1") {
1239 return;
1240 }
1241
1242 let options = SessionOptions {
1243 alternate_screen: true,
1244 mouse_capture: true,
1245 bracketed_paste: true,
1246 focus_events: true,
1247 kitty_keyboard: true,
1248 };
1249
1250 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1251 best_effort_cleanup_for_exit();
1252 std::process::exit(0);
1253 }
1254
1255 #[test]
1258 fn find_subsequence_empty_needle() {
1259 assert_eq!(find_subsequence(b"anything", b""), Some(0));
1260 }
1261
1262 #[test]
1263 fn find_subsequence_empty_haystack() {
1264 assert_eq!(find_subsequence(b"", b"x"), None);
1265 }
1266
1267 #[test]
1268 fn find_subsequence_found_at_start() {
1269 assert_eq!(find_subsequence(b"hello world", b"hello"), Some(0));
1270 }
1271
1272 #[test]
1273 fn find_subsequence_found_in_middle() {
1274 assert_eq!(find_subsequence(b"hello world", b"o w"), Some(4));
1275 }
1276
1277 #[test]
1278 fn find_subsequence_found_at_end() {
1279 assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
1280 }
1281
1282 #[test]
1283 fn find_subsequence_not_found() {
1284 assert_eq!(find_subsequence(b"hello world", b"xyz"), None);
1285 }
1286
1287 #[test]
1288 fn find_subsequence_needle_longer_than_haystack() {
1289 assert_eq!(find_subsequence(b"ab", b"abcdef"), None);
1290 }
1291
1292 #[test]
1293 fn find_subsequence_exact_match() {
1294 assert_eq!(find_subsequence(b"abc", b"abc"), Some(0));
1295 }
1296
1297 #[test]
1300 fn contains_any_finds_first_match() {
1301 assert!(contains_any(b"\x1b[0m test", &[b"\x1b[0m", b"\x1b[m"]));
1302 }
1303
1304 #[test]
1305 fn contains_any_finds_second_match() {
1306 assert!(contains_any(b"\x1b[m test", &[b"\x1b[0m", b"\x1b[m"]));
1307 }
1308
1309 #[test]
1310 fn contains_any_no_match() {
1311 assert!(!contains_any(b"plain text", &[b"\x1b[0m", b"\x1b[m"]));
1312 }
1313
1314 #[test]
1315 fn contains_any_empty_needles() {
1316 assert!(!contains_any(b"test", &[]));
1317 }
1318
1319 #[test]
1322 fn hex_preview_basic() {
1323 let result = hex_preview(&[0x41, 0x42, 0x43], 10);
1324 assert_eq!(result, "414243");
1325 }
1326
1327 #[test]
1328 fn hex_preview_truncated() {
1329 let result = hex_preview(&[0x00, 0x01, 0x02, 0x03, 0x04], 3);
1330 assert_eq!(result, "000102..");
1331 }
1332
1333 #[test]
1334 fn hex_preview_empty() {
1335 assert_eq!(hex_preview(&[], 10), "");
1336 }
1337
1338 #[test]
1341 fn hex_dump_single_row() {
1342 let result = hex_dump(&[0x41, 0x42], 100);
1343 assert!(result.starts_with("0000: "));
1344 assert!(result.contains("41 42"));
1345 }
1346
1347 #[test]
1348 fn hex_dump_multi_row() {
1349 let data: Vec<u8> = (0..20).collect();
1350 let result = hex_dump(&data, 100);
1351 assert!(result.contains("0000: "));
1352 assert!(result.contains("0010: ")); }
1354
1355 #[test]
1356 fn hex_dump_truncated() {
1357 let data: Vec<u8> = (0..100).collect();
1358 let result = hex_dump(&data, 32);
1359 assert!(result.contains("(truncated)"));
1360 }
1361
1362 #[test]
1363 fn hex_dump_empty() {
1364 let result = hex_dump(&[], 100);
1365 assert!(result.is_empty());
1366 }
1367
1368 #[test]
1371 fn printable_dump_ascii() {
1372 let result = printable_dump(b"Hello", 100);
1373 assert!(result.contains("Hello"));
1374 }
1375
1376 #[test]
1377 fn printable_dump_replaces_control_chars() {
1378 let result = printable_dump(&[0x01, 0x02, 0x1B], 100);
1379 assert!(result.contains("..."));
1381 }
1382
1383 #[test]
1384 fn printable_dump_truncated() {
1385 let data: Vec<u8> = (0..100).collect();
1386 let result = printable_dump(&data, 32);
1387 assert!(result.contains("(truncated)"));
1388 }
1389
1390 #[test]
1393 fn pty_config_defaults() {
1394 let config = PtyConfig::default();
1395 assert_eq!(config.cols, 80);
1396 assert_eq!(config.rows, 24);
1397 assert_eq!(config.term.as_deref(), Some("xterm-256color"));
1398 assert!(config.env.is_empty());
1399 assert!(config.test_name.is_none());
1400 assert!(config.log_events);
1401 }
1402
1403 #[test]
1404 fn pty_config_with_size() {
1405 let config = PtyConfig::default().with_size(120, 40);
1406 assert_eq!(config.cols, 120);
1407 assert_eq!(config.rows, 40);
1408 }
1409
1410 #[test]
1411 fn pty_config_with_term() {
1412 let config = PtyConfig::default().with_term("dumb");
1413 assert_eq!(config.term.as_deref(), Some("dumb"));
1414 }
1415
1416 #[test]
1417 fn pty_config_with_env() {
1418 let config = PtyConfig::default()
1419 .with_env("FOO", "bar")
1420 .with_env("BAZ", "qux");
1421 assert_eq!(config.env.len(), 2);
1422 assert_eq!(config.env[0], ("FOO".to_string(), "bar".to_string()));
1423 assert_eq!(config.env[1], ("BAZ".to_string(), "qux".to_string()));
1424 }
1425
1426 #[test]
1427 fn pty_config_with_test_name() {
1428 let config = PtyConfig::default().with_test_name("my_test");
1429 assert_eq!(config.test_name.as_deref(), Some("my_test"));
1430 }
1431
1432 #[test]
1433 fn pty_config_logging_disabled() {
1434 let config = PtyConfig::default().logging(false);
1435 assert!(!config.log_events);
1436 }
1437
1438 #[test]
1439 fn pty_config_builder_chaining() {
1440 let config = PtyConfig::default()
1441 .with_size(132, 50)
1442 .with_term("xterm")
1443 .with_env("KEY", "val")
1444 .with_test_name("chain_test")
1445 .logging(false);
1446 assert_eq!(config.cols, 132);
1447 assert_eq!(config.rows, 50);
1448 assert_eq!(config.term.as_deref(), Some("xterm"));
1449 assert_eq!(config.env.len(), 1);
1450 assert_eq!(config.test_name.as_deref(), Some("chain_test"));
1451 assert!(!config.log_events);
1452 }
1453
1454 #[test]
1457 fn read_until_options_defaults() {
1458 let opts = ReadUntilOptions::default();
1459 assert_eq!(opts.timeout, Duration::from_secs(5));
1460 assert_eq!(opts.max_retries, 0);
1461 assert_eq!(opts.retry_delay, Duration::from_millis(100));
1462 assert_eq!(opts.min_bytes, 0);
1463 }
1464
1465 #[test]
1466 fn read_until_options_with_timeout() {
1467 let opts = ReadUntilOptions::with_timeout(Duration::from_secs(10));
1468 assert_eq!(opts.timeout, Duration::from_secs(10));
1469 assert_eq!(opts.max_retries, 0); }
1471
1472 #[test]
1473 fn read_until_options_builder_chaining() {
1474 let opts = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1475 .retries(5)
1476 .retry_delay(Duration::from_millis(50))
1477 .min_bytes(100);
1478 assert_eq!(opts.timeout, Duration::from_secs(3));
1479 assert_eq!(opts.max_retries, 5);
1480 assert_eq!(opts.retry_delay, Duration::from_millis(50));
1481 assert_eq!(opts.min_bytes, 100);
1482 }
1483
1484 #[test]
1487 fn is_transient_error_would_block() {
1488 let err = io::Error::new(io::ErrorKind::WouldBlock, "test");
1489 assert!(is_transient_error(&err));
1490 }
1491
1492 #[test]
1493 fn is_transient_error_interrupted() {
1494 let err = io::Error::new(io::ErrorKind::Interrupted, "test");
1495 assert!(is_transient_error(&err));
1496 }
1497
1498 #[test]
1499 fn is_transient_error_timed_out() {
1500 let err = io::Error::new(io::ErrorKind::TimedOut, "test");
1501 assert!(is_transient_error(&err));
1502 }
1503
1504 #[test]
1505 fn is_transient_error_not_found() {
1506 let err = io::Error::new(io::ErrorKind::NotFound, "test");
1507 assert!(!is_transient_error(&err));
1508 }
1509
1510 #[test]
1511 fn is_transient_error_connection_refused() {
1512 let err = io::Error::new(io::ErrorKind::ConnectionRefused, "test");
1513 assert!(!is_transient_error(&err));
1514 }
1515
1516 #[test]
1519 fn cleanup_strict_all_true() {
1520 let strict = CleanupExpectations::strict();
1521 assert!(strict.sgr_reset);
1522 assert!(strict.show_cursor);
1523 assert!(strict.alt_screen);
1524 assert!(strict.mouse);
1525 assert!(strict.bracketed_paste);
1526 assert!(strict.focus_events);
1527 assert!(strict.kitty_keyboard);
1528 }
1529
1530 #[test]
1531 fn cleanup_for_session_matches_options() {
1532 let options = SessionOptions {
1533 alternate_screen: true,
1534 mouse_capture: false,
1535 bracketed_paste: true,
1536 focus_events: false,
1537 kitty_keyboard: true,
1538 };
1539 let expectations = CleanupExpectations::for_session(&options);
1540 assert!(!expectations.sgr_reset); assert!(expectations.show_cursor); assert!(expectations.alt_screen);
1543 assert!(!expectations.mouse);
1544 assert!(expectations.bracketed_paste);
1545 assert!(!expectations.focus_events);
1546 assert!(expectations.kitty_keyboard);
1547 }
1548
1549 #[test]
1550 fn cleanup_for_session_all_disabled() {
1551 let options = SessionOptions {
1552 alternate_screen: false,
1553 mouse_capture: false,
1554 bracketed_paste: false,
1555 focus_events: false,
1556 kitty_keyboard: false,
1557 };
1558 let expectations = CleanupExpectations::for_session(&options);
1559 assert!(expectations.show_cursor); assert!(!expectations.alt_screen);
1561 assert!(!expectations.mouse);
1562 assert!(!expectations.bracketed_paste);
1563 assert!(!expectations.focus_events);
1564 assert!(!expectations.kitty_keyboard);
1565 }
1566
1567 #[test]
1570 fn assert_restored_with_alt_sequence_variants() {
1571 let output1 = b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1573 assert_terminal_restored(output1, &CleanupExpectations::strict())
1574 .expect("terminal cleanup assertions failed");
1575
1576 let output2 = b"\x1b[0m\x1b[?25h\x1b[?1047l\x1b[?1000;1002l\x1b[?2004l\x1b[?1004l\x1b[<u";
1577 assert_terminal_restored(output2, &CleanupExpectations::strict())
1578 .expect("terminal cleanup assertions failed");
1579 }
1580
1581 #[test]
1582 fn assert_restored_sgr_reset_variant() {
1583 let output = b"\x1b[m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1585 assert_terminal_restored(output, &CleanupExpectations::strict())
1586 .expect("terminal cleanup assertions failed");
1587 }
1588
1589 #[test]
1590 fn assert_restored_partial_expectations() {
1591 let expectations = CleanupExpectations {
1593 sgr_reset: false,
1594 show_cursor: true,
1595 alt_screen: false,
1596 mouse: false,
1597 bracketed_paste: false,
1598 focus_events: false,
1599 kitty_keyboard: false,
1600 };
1601 assert_terminal_restored(b"\x1b[?25h", &expectations)
1602 .expect("terminal cleanup assertions failed");
1603 }
1604
1605 #[test]
1608 fn sequence_constants_are_nonempty() {
1609 assert!(!SGR_RESET_SEQS.is_empty());
1610 assert!(!CURSOR_SHOW_SEQS.is_empty());
1611 assert!(!ALT_SCREEN_EXIT_SEQS.is_empty());
1612 assert!(!MOUSE_DISABLE_SEQS.is_empty());
1613 assert!(!BRACKETED_PASTE_DISABLE_SEQS.is_empty());
1614 assert!(!FOCUS_DISABLE_SEQS.is_empty());
1615 assert!(!KITTY_DISABLE_SEQS.is_empty());
1616 }
1617}