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 pub input_write_timeout: Duration,
68}
69
70impl Default for PtyConfig {
71 fn default() -> Self {
72 Self {
73 cols: 80,
74 rows: 24,
75 term: Some("xterm-256color".to_string()),
76 env: Vec::new(),
77 test_name: None,
78 log_events: true,
79 input_write_timeout: DEFAULT_INPUT_WRITE_TIMEOUT,
80 }
81 }
82}
83
84impl PtyConfig {
85 #[must_use]
87 pub fn with_size(mut self, cols: u16, rows: u16) -> Self {
88 self.cols = cols;
89 self.rows = rows;
90 self
91 }
92
93 #[must_use]
95 pub fn with_term(mut self, term: impl Into<String>) -> Self {
96 self.term = Some(term.into());
97 self
98 }
99
100 #[must_use]
102 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
103 self.env.push((key.into(), value.into()));
104 self
105 }
106
107 #[must_use]
109 pub fn with_test_name(mut self, name: impl Into<String>) -> Self {
110 self.test_name = Some(name.into());
111 self
112 }
113
114 #[must_use]
116 pub fn logging(mut self, enabled: bool) -> Self {
117 self.log_events = enabled;
118 self
119 }
120
121 #[must_use]
123 pub fn with_input_write_timeout(mut self, timeout: Duration) -> Self {
124 self.input_write_timeout = timeout;
125 self
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct ReadUntilOptions {
132 pub timeout: Duration,
134 pub max_retries: u32,
136 pub retry_delay: Duration,
138 pub min_bytes: usize,
140}
141
142impl Default for ReadUntilOptions {
143 fn default() -> Self {
144 Self {
145 timeout: Duration::from_secs(5),
146 max_retries: 0,
147 retry_delay: Duration::from_millis(100),
148 min_bytes: 0,
149 }
150 }
151}
152
153impl ReadUntilOptions {
154 pub fn with_timeout(timeout: Duration) -> Self {
156 Self {
157 timeout,
158 ..Default::default()
159 }
160 }
161
162 #[must_use]
164 pub fn retries(mut self, count: u32) -> Self {
165 self.max_retries = count;
166 self
167 }
168
169 #[must_use]
171 pub fn retry_delay(mut self, delay: Duration) -> Self {
172 self.retry_delay = delay;
173 self
174 }
175
176 #[must_use]
178 pub fn min_bytes(mut self, bytes: usize) -> Self {
179 self.min_bytes = bytes;
180 self
181 }
182}
183
184#[derive(Debug, Clone)]
186pub struct CleanupExpectations {
187 pub sgr_reset: bool,
188 pub show_cursor: bool,
189 pub alt_screen: bool,
190 pub mouse: bool,
191 pub bracketed_paste: bool,
192 pub focus_events: bool,
193 pub kitty_keyboard: bool,
194}
195
196impl CleanupExpectations {
197 pub fn strict() -> Self {
199 Self {
200 sgr_reset: true,
201 show_cursor: true,
202 alt_screen: true,
203 mouse: true,
204 bracketed_paste: true,
205 focus_events: true,
206 kitty_keyboard: true,
207 }
208 }
209
210 pub fn for_session(options: &SessionOptions) -> Self {
212 Self {
213 sgr_reset: false,
214 show_cursor: true,
215 alt_screen: options.alternate_screen,
216 mouse: options.mouse_capture,
217 bracketed_paste: options.bracketed_paste,
218 focus_events: options.focus_events,
219 kitty_keyboard: options.kitty_keyboard,
220 }
221 }
222}
223
224pub(crate) const DEFAULT_INPUT_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
225
226pub(crate) fn normalize_line_input(line: &[u8]) -> Vec<u8> {
227 let trimmed = if line.last() == Some(&b'\n') {
228 &line[..line.len().saturating_sub(1)]
229 } else {
230 line
231 };
232
233 let mut normalized = Vec::with_capacity(trimmed.len() + 2);
234 normalized.extend_from_slice(trimmed);
235 if normalized.last() == Some(&b'\r') {
236 normalized.push(b'\n');
237 } else {
238 normalized.extend_from_slice(b"\r\n");
239 }
240 normalized
241}
242
243enum WriterCommand {
244 Write {
245 bytes: Vec<u8>,
246 response: mpsc::Sender<io::Result<()>>,
247 },
248 Flush {
249 response: mpsc::Sender<io::Result<()>>,
250 },
251}
252
253pub(crate) struct PtyInputWriter {
254 tx: mpsc::Sender<WriterCommand>,
255 thread: Option<thread::JoinHandle<()>>,
256}
257
258impl PtyInputWriter {
259 pub(crate) fn spawn(writer: Box<dyn Write + Send>, thread_name: &str) -> io::Result<Self> {
260 let (tx, rx) = mpsc::channel::<WriterCommand>();
261 let handle = thread::Builder::new()
262 .name(thread_name.to_string())
263 .spawn(move || {
264 let mut writer = writer;
265 while let Ok(command) = rx.recv() {
266 match command {
267 WriterCommand::Write { bytes, response } => {
268 let result = writer.write_all(&bytes).and_then(|_| writer.flush());
269 let _ = response.send(result);
270 }
271 WriterCommand::Flush { response } => {
272 let _ = response.send(writer.flush());
273 }
274 }
275 }
276 })
277 .map_err(|error| {
278 io::Error::other(format!("failed to spawn PTY writer thread: {error}"))
279 })?;
280
281 Ok(Self {
282 tx,
283 thread: Some(handle),
284 })
285 }
286
287 pub(crate) fn write_with_timeout(
288 &mut self,
289 bytes: &[u8],
290 timeout: Duration,
291 _worker_name: &str,
292 _detach_name: &str,
293 ) -> io::Result<()> {
294 let (response_tx, response_rx) = mpsc::channel::<io::Result<()>>();
295 self.tx
296 .send(WriterCommand::Write {
297 bytes: bytes.to_vec(),
298 response: response_tx,
299 })
300 .map_err(|_| {
301 io::Error::new(io::ErrorKind::BrokenPipe, "PTY input writer is unavailable")
302 })?;
303
304 match response_rx.recv_timeout(timeout) {
305 Ok(result) => result,
306 Err(mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new(
307 io::ErrorKind::TimedOut,
308 format!("PTY input write timed out after {} ms", timeout.as_millis()),
309 )),
310 Err(mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::new(
311 io::ErrorKind::BrokenPipe,
312 "PTY input writer thread exited unexpectedly",
313 )),
314 }
315 }
316
317 pub(crate) fn flush_best_effort(&mut self) {
318 let (response_tx, response_rx) = mpsc::channel::<io::Result<()>>();
319 if self
320 .tx
321 .send(WriterCommand::Flush {
322 response: response_tx,
323 })
324 .is_ok()
325 {
326 let _ = response_rx.recv_timeout(Duration::from_millis(100));
327 }
328 }
329
330 pub(crate) fn detach_thread(&mut self, detach_name: &str) {
331 if let Some(handle) = self.thread.take() {
332 detach_join(handle, detach_name);
333 }
334 }
335}
336
337#[derive(Debug)]
338enum ReaderMsg {
339 Data(Vec<u8>),
340 Eof,
341 Err(io::Error),
342}
343
344pub struct PtySession {
346 child: Box<dyn portable_pty::Child + Send + Sync>,
347 input_writer: PtyInputWriter,
348 rx: mpsc::Receiver<ReaderMsg>,
349 reader_thread: Option<thread::JoinHandle<()>>,
350 captured: Vec<u8>,
351 eof: bool,
352 config: PtyConfig,
353}
354
355impl fmt::Debug for PtySession {
356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357 f.debug_struct("PtySession")
358 .field("child_pid", &self.child.process_id())
359 .field("captured_len", &self.captured.len())
360 .field("eof", &self.eof)
361 .field("config", &self.config)
362 .finish()
363 }
364}
365
366pub fn spawn_command(mut config: PtyConfig, mut cmd: CommandBuilder) -> io::Result<PtySession> {
370 if let Some(name) = config.test_name.as_ref() {
371 log_event(config.log_events, "PTY_TEST_START", name);
372 }
373
374 if let Some(term) = config.term.take() {
375 cmd.env("TERM", term);
376 }
377 for (k, v) in config.env.drain(..) {
378 cmd.env(k, v);
379 }
380
381 let pty_system = portable_pty::native_pty_system();
382 let pair = pty_system
383 .openpty(PtySize {
384 rows: config.rows,
385 cols: config.cols,
386 pixel_width: 0,
387 pixel_height: 0,
388 })
389 .map_err(portable_pty_error)?;
390
391 let child = pair.slave.spawn_command(cmd).map_err(portable_pty_error)?;
392 let mut reader = pair.master.try_clone_reader().map_err(portable_pty_error)?;
393 let writer = pair.master.take_writer().map_err(portable_pty_error)?;
394 let input_writer = PtyInputWriter::spawn(writer, "ftui-pty-session-writer")?;
395
396 let (tx, rx) = mpsc::channel::<ReaderMsg>();
397 let reader_thread = thread::spawn(move || {
398 let mut buf = [0u8; 8192];
399 loop {
400 match reader.read(&mut buf) {
401 Ok(0) => {
402 let _ = tx.send(ReaderMsg::Eof);
403 break;
404 }
405 Ok(n) => {
406 let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
407 }
408 Err(err) => {
409 let _ = tx.send(ReaderMsg::Err(err));
410 break;
411 }
412 }
413 }
414 });
415
416 Ok(PtySession {
417 child,
418 input_writer,
419 rx,
420 reader_thread: Some(reader_thread),
421 captured: Vec::new(),
422 eof: false,
423 config,
424 })
425}
426
427impl PtySession {
428 pub fn read_output(&mut self) -> Vec<u8> {
430 match self.read_output_result() {
431 Ok(output) => output,
432 Err(err) => {
433 log_event(
434 self.config.log_events,
435 "PTY_READ_ERROR",
436 format!("error={err}"),
437 );
438 self.captured.clone()
439 }
440 }
441 }
442
443 pub fn read_output_result(&mut self) -> io::Result<Vec<u8>> {
445 let _ = self.read_available(Duration::from_millis(0))?;
446 Ok(self.captured.clone())
447 }
448
449 pub fn read_until(&mut self, pattern: &[u8], timeout: Duration) -> io::Result<Vec<u8>> {
452 let options = ReadUntilOptions::with_timeout(timeout)
453 .retries(3)
454 .retry_delay(Duration::from_millis(25));
455 self.read_until_with_options(pattern, options)
456 }
457
458 pub fn read_until_with_options(
474 &mut self,
475 pattern: &[u8],
476 options: ReadUntilOptions,
477 ) -> io::Result<Vec<u8>> {
478 if pattern.is_empty() {
479 return Ok(self.captured.clone());
480 }
481
482 let deadline = deadline_after(options.timeout, "PTY read_until")?;
483 let mut retries_remaining = options.max_retries;
484 let mut last_error: Option<io::Error> = None;
485
486 loop {
487 if self.captured.len() >= options.min_bytes
489 && find_subsequence(&self.captured, pattern).is_some()
490 {
491 log_event(
492 self.config.log_events,
493 "PTY_CHECK",
494 format!(
495 "pattern_found=0x{} bytes={}",
496 hex_preview(pattern, 16).trim(),
497 self.captured.len()
498 ),
499 );
500 return Ok(self.captured.clone());
501 }
502
503 if self.eof || Instant::now() >= deadline {
504 break;
505 }
506
507 let remaining = deadline.saturating_duration_since(Instant::now());
508 match self.read_available(remaining) {
509 Ok(_) => {
510 retries_remaining = options.max_retries;
512 last_error = None;
513 }
514 Err(err) if is_transient_error(&err) => {
515 if retries_remaining > 0 {
516 retries_remaining -= 1;
517 log_event(
518 self.config.log_events,
519 "PTY_RETRY",
520 format!(
521 "transient_error={} retries_left={}",
522 err.kind(),
523 retries_remaining
524 ),
525 );
526 std::thread::sleep(options.retry_delay.min(remaining));
527 last_error = Some(err);
528 continue;
529 }
530 return Err(err);
531 }
532 Err(err) => return Err(err),
533 }
534 }
535
536 if let Some(err) = last_error {
538 return Err(io::Error::new(
539 err.kind(),
540 format!("PTY read_until failed after retries: {}", err),
541 ));
542 }
543
544 Err(io::Error::new(
545 io::ErrorKind::TimedOut,
546 format!(
547 "PTY read_until timed out (captured {} bytes, need {} + pattern)",
548 self.captured.len(),
549 options.min_bytes
550 ),
551 ))
552 }
553
554 pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
559 if bytes.is_empty() {
560 return Ok(());
561 }
562
563 let result = self.input_writer.write_with_timeout(
564 bytes,
565 self.config.input_write_timeout,
566 "ftui-pty-session-write",
567 "ftui-pty-session-detached-write",
568 );
569 if matches!(
570 result.as_ref().err().map(io::Error::kind),
571 Some(io::ErrorKind::TimedOut)
572 ) {
573 let _ = self.child.kill();
574 }
575 result?;
576
577 log_event(
578 self.config.log_events,
579 "PTY_INPUT",
580 format!("sent_bytes={}", bytes.len()),
581 );
582
583 Ok(())
584 }
585
586 pub fn send_line(&mut self, line: impl AsRef<[u8]>) -> io::Result<()> {
588 let normalized = normalize_line_input(line.as_ref());
589 self.send_input(&normalized)
590 }
591
592 pub fn wait(&mut self) -> io::Result<ExitStatus> {
594 self.child.wait()
595 }
596
597 pub fn output(&self) -> &[u8] {
599 &self.captured
600 }
601
602 pub fn child_pid(&self) -> Option<u32> {
604 self.child.process_id()
605 }
606
607 fn read_available(&mut self, timeout: Duration) -> io::Result<usize> {
608 if self.eof {
609 return Ok(0);
610 }
611
612 let mut total = 0usize;
613
614 let first = if timeout.is_zero() {
616 match self.rx.try_recv() {
617 Ok(msg) => Some(msg),
618 Err(mpsc::TryRecvError::Empty) => None,
619 Err(mpsc::TryRecvError::Disconnected) => {
620 self.eof = true;
621 None
622 }
623 }
624 } else {
625 match self.rx.recv_timeout(timeout) {
626 Ok(msg) => Some(msg),
627 Err(mpsc::RecvTimeoutError::Timeout) => None,
628 Err(mpsc::RecvTimeoutError::Disconnected) => {
629 self.eof = true;
630 None
631 }
632 }
633 };
634
635 let mut msg = match first {
636 Some(m) => m,
637 None => return Ok(0),
638 };
639
640 loop {
641 match msg {
642 ReaderMsg::Data(bytes) => {
643 total = total.saturating_add(bytes.len());
644 self.captured.extend_from_slice(&bytes);
645 }
646 ReaderMsg::Eof => {
647 self.eof = true;
648 break;
649 }
650 ReaderMsg::Err(err) => return Err(err),
651 }
652
653 match self.rx.try_recv() {
654 Ok(next) => msg = next,
655 Err(mpsc::TryRecvError::Empty) => break,
656 Err(mpsc::TryRecvError::Disconnected) => {
657 self.eof = true;
658 break;
659 }
660 }
661 }
662
663 if total > 0 {
664 log_event(
665 self.config.log_events,
666 "PTY_OUTPUT",
667 format!("captured_bytes={}", total),
668 );
669 }
670
671 Ok(total)
672 }
673
674 pub fn drain_remaining(&mut self, timeout: Duration) -> io::Result<usize> {
682 if self.eof {
683 return Ok(0);
684 }
685
686 let deadline = deadline_after(timeout, "PTY drain_remaining")?;
687 let mut total = 0usize;
688
689 log_event(
690 self.config.log_events,
691 "PTY_DRAIN_START",
692 format!("timeout_ms={}", timeout.as_millis()),
693 );
694
695 loop {
696 if self.eof {
697 break;
698 }
699
700 let remaining = deadline.saturating_duration_since(Instant::now());
701 if remaining.is_zero() {
702 log_event(
703 self.config.log_events,
704 "PTY_DRAIN_TIMEOUT",
705 format!("captured_bytes={}", total),
706 );
707 break;
708 }
709
710 let msg = match self.rx.recv_timeout(remaining) {
712 Ok(msg) => msg,
713 Err(mpsc::RecvTimeoutError::Timeout) => break,
714 Err(mpsc::RecvTimeoutError::Disconnected) => {
715 self.eof = true;
716 break;
717 }
718 };
719
720 match msg {
721 ReaderMsg::Data(bytes) => {
722 total = total.saturating_add(bytes.len());
723 self.captured.extend_from_slice(&bytes);
724 }
725 ReaderMsg::Eof => {
726 self.eof = true;
727 break;
728 }
729 ReaderMsg::Err(err) => return Err(err),
730 }
731
732 loop {
734 match self.rx.try_recv() {
735 Ok(ReaderMsg::Data(bytes)) => {
736 total = total.saturating_add(bytes.len());
737 self.captured.extend_from_slice(&bytes);
738 }
739 Ok(ReaderMsg::Eof) => {
740 self.eof = true;
741 break;
742 }
743 Ok(ReaderMsg::Err(err)) => return Err(err),
744 Err(mpsc::TryRecvError::Empty) => break,
745 Err(mpsc::TryRecvError::Disconnected) => {
746 self.eof = true;
747 break;
748 }
749 }
750 }
751 }
752
753 log_event(
754 self.config.log_events,
755 "PTY_DRAIN_COMPLETE",
756 format!("captured_bytes={} eof={}", total, self.eof),
757 );
758
759 Ok(total)
760 }
761
762 pub fn wait_and_drain(&mut self, drain_timeout: Duration) -> io::Result<ExitStatus> {
768 let status = self.child.wait()?;
769 let _ = self.drain_remaining(drain_timeout)?;
770 Ok(status)
771 }
772}
773
774impl Drop for PtySession {
775 fn drop(&mut self) {
776 let _ = self.child.kill();
777 self.input_writer.flush_best_effort();
778 self.input_writer
779 .detach_thread("ftui-pty-session-detached-writer");
780
781 if let Some(handle) = self.reader_thread.take() {
782 detach_reader_join(handle);
783 }
784 }
785}
786
787fn detach_reader_join(handle: thread::JoinHandle<()>) {
788 detach_join(handle, "ftui-pty-detached-reader-join");
789}
790
791pub(crate) fn deadline_after(timeout: Duration, operation: &str) -> io::Result<Instant> {
792 Instant::now().checked_add(timeout).ok_or_else(|| {
793 io::Error::new(
794 io::ErrorKind::InvalidInput,
795 format!("{operation}: timeout is too large"),
796 )
797 })
798}
799
800pub(crate) fn detach_join(handle: thread::JoinHandle<()>, thread_name: &str) {
801 let _ = thread::Builder::new()
802 .name(thread_name.to_string())
803 .spawn(move || {
804 let _ = handle.join();
805 });
806}
807
808pub fn assert_terminal_restored(
810 output: &[u8],
811 expectations: &CleanupExpectations,
812) -> Result<(), String> {
813 let mut failures = Vec::new();
814
815 if expectations.sgr_reset && !contains_any(output, SGR_RESET_SEQS) {
816 failures.push("Missing SGR reset (CSI 0 m)");
817 }
818 if expectations.show_cursor && !contains_any(output, CURSOR_SHOW_SEQS) {
819 failures.push("Missing cursor show (CSI ? 25 h)");
820 }
821 if expectations.alt_screen && !contains_any(output, ALT_SCREEN_EXIT_SEQS) {
822 failures.push("Missing alt-screen exit (CSI ? 1049 l)");
823 }
824 if expectations.mouse && !contains_any(output, MOUSE_DISABLE_SEQS) {
825 failures.push("Missing mouse disable (CSI ? 1000... l)");
826 }
827 if expectations.bracketed_paste && !contains_any(output, BRACKETED_PASTE_DISABLE_SEQS) {
828 failures.push("Missing bracketed paste disable (CSI ? 2004 l)");
829 }
830 if expectations.focus_events && !contains_any(output, FOCUS_DISABLE_SEQS) {
831 failures.push("Missing focus disable (CSI ? 1004 l)");
832 }
833 if expectations.kitty_keyboard && !contains_any(output, KITTY_DISABLE_SEQS) {
834 failures.push("Missing kitty keyboard disable (CSI < u)");
835 }
836
837 if failures.is_empty() {
838 log_event(true, "PTY_TEST_PASS", "terminal cleanup sequences verified");
839 return Ok(());
840 }
841
842 for failure in &failures {
843 log_event(true, "PTY_FAILURE_REASON", *failure);
844 }
845
846 log_event(true, "PTY_OUTPUT_DUMP", "hex:");
847 for line in hex_dump(output, 4096).lines() {
848 log_event(true, "PTY_OUTPUT_DUMP", line);
849 }
850
851 log_event(true, "PTY_OUTPUT_DUMP", "printable:");
852 for line in printable_dump(output, 4096).lines() {
853 log_event(true, "PTY_OUTPUT_DUMP", line);
854 }
855
856 Err(failures.join("; "))
857}
858
859fn log_event(enabled: bool, event: &str, detail: impl fmt::Display) {
860 if !enabled {
861 return;
862 }
863
864 let timestamp = timestamp_rfc3339();
865 eprintln!("[{}] {}: {}", timestamp, event, detail);
866}
867
868fn timestamp_rfc3339() -> String {
869 time::OffsetDateTime::now_utc()
870 .format(&time::format_description::well_known::Rfc3339)
871 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
872}
873
874fn hex_preview(bytes: &[u8], limit: usize) -> String {
875 let mut out = String::new();
876 for b in bytes.iter().take(limit) {
877 out.push_str(&format!("{:02x}", b));
878 }
879 if bytes.len() > limit {
880 out.push_str("..");
881 }
882 out
883}
884
885fn hex_dump(bytes: &[u8], limit: usize) -> String {
886 let mut out = String::new();
887 let slice = bytes.get(0..limit).unwrap_or(bytes);
888
889 for (row, chunk) in slice.chunks(16).enumerate() {
890 let offset = row * 16;
891 out.push_str(&format!("{:04x}: ", offset));
892 for b in chunk {
893 out.push_str(&format!("{:02x} ", b));
894 }
895 out.push('\n');
896 }
897
898 if bytes.len() > limit {
899 out.push_str("... (truncated)\n");
900 }
901
902 out
903}
904
905fn printable_dump(bytes: &[u8], limit: usize) -> String {
906 let mut out = String::new();
907 let slice = bytes.get(0..limit).unwrap_or(bytes);
908
909 for (row, chunk) in slice.chunks(16).enumerate() {
910 let offset = row * 16;
911 out.push_str(&format!("{:04x}: ", offset));
912 for b in chunk {
913 let ch = if b.is_ascii_graphic() || *b == b' ' {
914 *b as char
915 } else {
916 '.'
917 };
918 out.push(ch);
919 }
920 out.push('\n');
921 }
922
923 if bytes.len() > limit {
924 out.push_str("... (truncated)\n");
925 }
926
927 out
928}
929
930fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
931 if needle.is_empty() {
932 return Some(0);
933 }
934 haystack
935 .windows(needle.len())
936 .position(|window| window == needle)
937}
938
939fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
940 needles
941 .iter()
942 .any(|needle| find_subsequence(haystack, needle).is_some())
943}
944
945fn portable_pty_error<E: fmt::Display>(err: E) -> io::Error {
946 io::Error::other(err.to_string())
947}
948
949fn is_transient_error(err: &io::Error) -> bool {
951 matches!(
952 err.kind(),
953 io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted | io::ErrorKind::TimedOut
954 )
955}
956
957const SGR_RESET_SEQS: &[&[u8]] = &[b"\x1b[0m", b"\x1b[m"];
958const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
959const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
960const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
961 b"\x1b[?1000l\x1b[?1002l\x1b[?1006l",
962 b"\x1b[?1002l\x1b[?1006l",
963 b"\x1b[?1000;1002;1006l",
964 b"\x1b[?1000;1002l",
965 b"\x1b[?1000l",
966 b"\x1b[?1002l",
967 b"\x1b[?1006l",
968];
969const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
970const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
971const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976
977 #[test]
978 fn normalize_line_input_appends_carriage_return() {
979 assert_eq!(normalize_line_input(b"echo hi"), b"echo hi\r\n");
980 }
981
982 #[test]
983 fn normalize_line_input_replaces_trailing_line_feed() {
984 assert_eq!(normalize_line_input(b"echo hi\n"), b"echo hi\r\n");
985 }
986
987 #[test]
988 fn normalize_line_input_preserves_existing_carriage_return() {
989 assert_eq!(normalize_line_input(b"echo hi\r"), b"echo hi\r\n");
990 }
991 #[cfg(unix)]
992 use ftui_core::terminal_session::{TerminalSession, best_effort_cleanup_for_exit};
993
994 #[test]
995 fn cleanup_expectations_match_sequences() {
996 let output =
997 b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000;1002;1006l\x1b[?2004l\x1b[?1004l\x1b[<u";
998 assert_terminal_restored(output, &CleanupExpectations::strict())
999 .expect("terminal cleanup assertions failed");
1000 }
1001
1002 #[test]
1003 #[should_panic]
1004 fn cleanup_expectations_fail_when_missing() {
1005 let output = b"\x1b[?25h";
1006 assert_terminal_restored(output, &CleanupExpectations::strict())
1007 .expect("terminal cleanup assertions failed");
1008 }
1009
1010 #[cfg(unix)]
1011 #[test]
1012 fn spawn_command_captures_output() {
1013 let config = PtyConfig::default().logging(false);
1014
1015 let mut cmd = CommandBuilder::new("sh");
1016 cmd.args(["-c", "printf hello-pty"]);
1017
1018 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1019
1020 let _status = session.wait().expect("wait should succeed");
1021 let output = session
1025 .read_until(b"hello-pty", Duration::from_secs(5))
1026 .expect("expected PTY output to contain test string");
1027 assert!(
1028 output
1029 .windows(b"hello-pty".len())
1030 .any(|w| w == b"hello-pty"),
1031 "expected PTY output to contain test string"
1032 );
1033 }
1034
1035 #[cfg(unix)]
1036 #[test]
1037 fn read_until_with_options_min_bytes() {
1038 let config = PtyConfig::default().logging(false);
1039
1040 let mut cmd = CommandBuilder::new("sh");
1041 cmd.args(["-c", "printf 'short'; sleep 0.05; printf 'longer-output'"]);
1042
1043 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1044
1045 let options = ReadUntilOptions::with_timeout(Duration::from_secs(5)).min_bytes(10);
1047
1048 let output = session
1049 .read_until_with_options(b"output", options)
1050 .expect("expected to find pattern with min_bytes");
1051
1052 assert!(
1053 output.len() >= 10,
1054 "expected at least 10 bytes, got {}",
1055 output.len()
1056 );
1057 assert!(
1058 output.windows(b"output".len()).any(|w| w == b"output"),
1059 "expected pattern 'output' in captured data"
1060 );
1061 }
1062
1063 #[cfg(unix)]
1064 #[test]
1065 fn read_until_with_options_retries_on_timeout_then_succeeds() {
1066 let config = PtyConfig::default().logging(false);
1067
1068 let mut cmd = CommandBuilder::new("sh");
1069 cmd.args(["-c", "sleep 0.1; printf done"]);
1070
1071 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1072
1073 let options = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1075 .retries(3)
1076 .retry_delay(Duration::from_millis(50));
1077
1078 let output = session
1079 .read_until_with_options(b"done", options)
1080 .expect("should succeed with retries");
1081
1082 assert!(
1083 output.windows(b"done".len()).any(|w| w == b"done"),
1084 "expected 'done' in output"
1085 );
1086 }
1087
1088 #[cfg(unix)]
1091 #[test]
1092 fn large_output_fully_captured() {
1093 let config = PtyConfig::default().logging(false);
1094
1095 let mut cmd = CommandBuilder::new("sh");
1097 cmd.args(["-c", "dd if=/dev/zero bs=1024 count=64 2>/dev/null | od -v"]);
1098
1099 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1100
1101 let _status = session
1102 .wait_and_drain(Duration::from_secs(5))
1103 .expect("wait_and_drain");
1104
1105 let output = session.output();
1107 assert!(
1108 output.len() > 50_000,
1109 "expected >50KB of output, got {} bytes",
1110 output.len()
1111 );
1112 }
1113
1114 #[cfg(unix)]
1115 #[test]
1116 fn late_output_after_exit_captured() {
1117 let config = PtyConfig::default().logging(false);
1118
1119 let mut cmd = CommandBuilder::new("sh");
1121 cmd.args([
1122 "-c",
1123 "printf 'start\\n'; sleep 0.05; printf 'middle\\n'; sleep 0.05; printf 'end\\n'",
1124 ]);
1125
1126 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1127
1128 let _status = session.wait().expect("wait should succeed");
1130
1131 let _drained = session
1133 .drain_remaining(Duration::from_secs(2))
1134 .expect("drain_remaining should succeed");
1135
1136 let output = session.output();
1137 let output_str = String::from_utf8_lossy(output);
1138
1139 assert!(
1141 output_str.contains("start"),
1142 "missing 'start' in output: {output_str:?}"
1143 );
1144 assert!(
1145 output_str.contains("middle"),
1146 "missing 'middle' in output: {output_str:?}"
1147 );
1148 assert!(
1149 output_str.contains("end"),
1150 "missing 'end' in output: {output_str:?}"
1151 );
1152
1153 let start_pos = output_str.find("start").unwrap();
1155 let middle_pos = output_str.find("middle").unwrap();
1156 let end_pos = output_str.find("end").unwrap();
1157 assert!(
1158 start_pos < middle_pos && middle_pos < end_pos,
1159 "output not in expected order: start={start_pos}, middle={middle_pos}, end={end_pos}"
1160 );
1161
1162 let drained_again = session
1164 .drain_remaining(Duration::from_millis(100))
1165 .expect("second drain should succeed");
1166 assert_eq!(drained_again, 0, "second drain should return 0");
1167 }
1168
1169 #[cfg(unix)]
1170 #[test]
1171 fn wait_and_drain_captures_all() {
1172 let config = PtyConfig::default().logging(false);
1173
1174 let mut cmd = CommandBuilder::new("sh");
1175 cmd.args([
1176 "-c",
1177 "for i in 1 2 3 4 5; do printf \"line$i\\n\"; sleep 0.02; done",
1178 ]);
1179
1180 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1181
1182 let status = session
1184 .wait_and_drain(Duration::from_secs(2))
1185 .expect("wait_and_drain should succeed");
1186
1187 assert!(status.success(), "child should succeed");
1188
1189 let output = session.output();
1190 let output_str = String::from_utf8_lossy(output);
1191
1192 for i in 1..=5 {
1194 assert!(
1195 output_str.contains(&format!("line{i}")),
1196 "missing 'line{i}' in output: {output_str:?}"
1197 );
1198 }
1199 }
1200
1201 #[cfg(unix)]
1202 #[test]
1203 fn wait_and_drain_large_output_ordered() {
1204 let config = PtyConfig::default().logging(false);
1205
1206 let mut cmd = CommandBuilder::new("sh");
1207 cmd.args([
1208 "-c",
1209 "i=1; while [ $i -le 1200 ]; do printf \"line%04d\\n\" $i; i=$((i+1)); done",
1210 ]);
1211
1212 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1213
1214 let status = session
1215 .wait_and_drain(Duration::from_secs(3))
1216 .expect("wait_and_drain should succeed");
1217
1218 assert!(status.success(), "child should succeed");
1219
1220 let output = session.output();
1221 let output_str = String::from_utf8_lossy(output);
1222 let lines: Vec<&str> = output_str.lines().collect();
1223
1224 assert_eq!(
1225 lines.len(),
1226 1200,
1227 "expected 1200 lines, got {}",
1228 lines.len()
1229 );
1230 assert_eq!(lines.first().copied(), Some("line0001"));
1231 assert_eq!(lines.last().copied(), Some("line1200"));
1232 }
1233
1234 #[cfg(unix)]
1235 #[test]
1236 fn drain_remaining_respects_eof() {
1237 let config = PtyConfig::default().logging(false);
1238
1239 let mut cmd = CommandBuilder::new("sh");
1240 cmd.args(["-c", "printf 'quick'"]);
1241
1242 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1243
1244 let _ = session
1246 .wait_and_drain(Duration::from_secs(2))
1247 .expect("wait_and_drain");
1248
1249 assert!(session.eof, "should be at EOF after wait_and_drain");
1251
1252 let result = session
1254 .drain_remaining(Duration::from_secs(1))
1255 .expect("drain");
1256 assert_eq!(result, 0, "drain after EOF should return 0");
1257 }
1258
1259 #[cfg(unix)]
1260 #[test]
1261 fn pty_terminal_session_cleanup() {
1262 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1263 cmd.args([
1264 "--exact",
1265 "tests::pty_terminal_session_cleanup_child",
1266 "--nocapture",
1267 ]);
1268 cmd.env("FTUI_PTY_CHILD", "1");
1269 cmd.env("FTUI_TEST_PROFILE", "modern");
1270 cmd.env("TERM", "xterm-256color");
1271
1272 let config = PtyConfig::default()
1273 .with_test_name("terminal_session_cleanup")
1274 .logging(false);
1275 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1276
1277 let status = session.wait().expect("wait for child");
1278 assert!(status.success(), "child test failed: {:?}", status);
1279
1280 let _ = session
1281 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1282 .expect("expected cursor show sequence");
1283 let _ = session
1284 .drain_remaining(Duration::from_secs(1))
1285 .expect("drain remaining");
1286 let output = session.output();
1287
1288 let options = SessionOptions {
1289 alternate_screen: true,
1290 mouse_capture: true,
1291 bracketed_paste: true,
1292 focus_events: true,
1293 kitty_keyboard: true,
1294 intercept_signals: true,
1295 };
1296 let expectations = CleanupExpectations::for_session(&options);
1297 assert_terminal_restored(output, &expectations)
1298 .expect("terminal cleanup assertions failed");
1299 }
1300
1301 #[cfg(unix)]
1302 #[test]
1303 fn pty_terminal_session_cleanup_child() {
1304 if std::env::var("FTUI_PTY_CHILD").as_deref() != Ok("1") {
1305 return;
1306 }
1307
1308 let options = SessionOptions {
1309 alternate_screen: true,
1310 mouse_capture: true,
1311 bracketed_paste: true,
1312 focus_events: true,
1313 kitty_keyboard: true,
1314 intercept_signals: true,
1315 };
1316
1317 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1318 }
1319
1320 #[cfg(unix)]
1321 #[test]
1322 fn pty_terminal_session_cleanup_on_panic() {
1323 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1324 cmd.args([
1325 "--exact",
1326 "tests::pty_terminal_session_cleanup_panic_child",
1327 "--nocapture",
1328 ]);
1329 cmd.env("FTUI_PTY_PANIC_CHILD", "1");
1330 cmd.env("FTUI_TEST_PROFILE", "modern");
1331 cmd.env("TERM", "xterm-256color");
1332
1333 let config = PtyConfig::default()
1334 .with_test_name("terminal_session_cleanup_panic")
1335 .logging(false);
1336 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1337
1338 let status = session.wait().expect("wait for child");
1339 assert!(
1340 !status.success(),
1341 "panic child should exit with failure status"
1342 );
1343
1344 let _ = session
1345 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1346 .expect("expected cursor show sequence");
1347 let _ = session
1348 .drain_remaining(Duration::from_secs(1))
1349 .expect("drain remaining");
1350 let output = session.output();
1351
1352 let options = SessionOptions {
1353 alternate_screen: true,
1354 mouse_capture: true,
1355 bracketed_paste: true,
1356 focus_events: true,
1357 kitty_keyboard: true,
1358 intercept_signals: true,
1359 };
1360 let expectations = CleanupExpectations::for_session(&options);
1361 assert_terminal_restored(output, &expectations)
1362 .expect("terminal cleanup assertions failed");
1363 }
1364
1365 #[cfg(unix)]
1366 #[test]
1367 fn pty_terminal_session_cleanup_panic_child() {
1368 if std::env::var("FTUI_PTY_PANIC_CHILD").as_deref() != Ok("1") {
1369 return;
1370 }
1371
1372 let options = SessionOptions {
1373 alternate_screen: true,
1374 mouse_capture: true,
1375 bracketed_paste: true,
1376 focus_events: true,
1377 kitty_keyboard: true,
1378 intercept_signals: true,
1379 };
1380
1381 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1382 std::panic::panic_any("intentional panic to verify cleanup on unwind");
1383 }
1384
1385 #[cfg(unix)]
1386 #[test]
1387 fn pty_terminal_session_cleanup_on_exit() {
1388 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1389 cmd.args([
1390 "--exact",
1391 "tests::pty_terminal_session_cleanup_exit_child",
1392 "--nocapture",
1393 ]);
1394 cmd.env("FTUI_PTY_EXIT_CHILD", "1");
1395 cmd.env("FTUI_TEST_PROFILE", "modern");
1396 cmd.env("TERM", "xterm-256color");
1397
1398 let config = PtyConfig::default()
1399 .with_test_name("terminal_session_cleanup_exit")
1400 .logging(false);
1401 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1402
1403 let status = session.wait().expect("wait for child");
1404 assert!(status.success(), "exit child should succeed: {:?}", status);
1405
1406 let _ = session
1407 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1408 .expect("expected cursor show sequence");
1409 let _ = session
1410 .drain_remaining(Duration::from_secs(1))
1411 .expect("drain remaining");
1412 let output = session.output();
1413
1414 let options = SessionOptions {
1415 alternate_screen: true,
1416 mouse_capture: true,
1417 bracketed_paste: true,
1418 focus_events: true,
1419 kitty_keyboard: true,
1420 intercept_signals: true,
1421 };
1422 let expectations = CleanupExpectations::for_session(&options);
1423 assert_terminal_restored(output, &expectations)
1424 .expect("terminal cleanup assertions failed");
1425 }
1426
1427 #[cfg(unix)]
1428 #[test]
1429 fn pty_terminal_session_cleanup_exit_child() {
1430 if std::env::var("FTUI_PTY_EXIT_CHILD").as_deref() != Ok("1") {
1431 return;
1432 }
1433
1434 let options = SessionOptions {
1435 alternate_screen: true,
1436 mouse_capture: true,
1437 bracketed_paste: true,
1438 focus_events: true,
1439 kitty_keyboard: true,
1440 intercept_signals: true,
1441 };
1442
1443 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1444 best_effort_cleanup_for_exit();
1445 std::process::exit(0);
1446 }
1447
1448 #[test]
1451 fn find_subsequence_empty_needle() {
1452 assert_eq!(find_subsequence(b"anything", b""), Some(0));
1453 }
1454
1455 #[test]
1456 fn find_subsequence_empty_haystack() {
1457 assert_eq!(find_subsequence(b"", b"x"), None);
1458 }
1459
1460 #[test]
1461 fn find_subsequence_found_at_start() {
1462 assert_eq!(find_subsequence(b"hello world", b"hello"), Some(0));
1463 }
1464
1465 #[test]
1466 fn find_subsequence_found_in_middle() {
1467 assert_eq!(find_subsequence(b"hello world", b"o w"), Some(4));
1468 }
1469
1470 #[test]
1471 fn find_subsequence_found_at_end() {
1472 assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
1473 }
1474
1475 #[test]
1476 fn find_subsequence_not_found() {
1477 assert_eq!(find_subsequence(b"hello world", b"xyz"), None);
1478 }
1479
1480 #[test]
1481 fn find_subsequence_needle_longer_than_haystack() {
1482 assert_eq!(find_subsequence(b"ab", b"abcdef"), None);
1483 }
1484
1485 #[test]
1486 fn find_subsequence_exact_match() {
1487 assert_eq!(find_subsequence(b"abc", b"abc"), Some(0));
1488 }
1489
1490 #[test]
1493 fn contains_any_finds_first_match() {
1494 assert!(contains_any(b"\x1b[0m test", &[b"\x1b[0m", b"\x1b[m"]));
1495 }
1496
1497 #[test]
1498 fn contains_any_finds_second_match() {
1499 assert!(contains_any(b"\x1b[m test", &[b"\x1b[0m", b"\x1b[m"]));
1500 }
1501
1502 #[test]
1503 fn contains_any_no_match() {
1504 assert!(!contains_any(b"plain text", &[b"\x1b[0m", b"\x1b[m"]));
1505 }
1506
1507 #[test]
1508 fn contains_any_empty_needles() {
1509 assert!(!contains_any(b"test", &[]));
1510 }
1511
1512 #[test]
1515 fn hex_preview_basic() {
1516 let result = hex_preview(&[0x41, 0x42, 0x43], 10);
1517 assert_eq!(result, "414243");
1518 }
1519
1520 #[test]
1521 fn hex_preview_truncated() {
1522 let result = hex_preview(&[0x00, 0x01, 0x02, 0x03, 0x04], 3);
1523 assert_eq!(result, "000102..");
1524 }
1525
1526 #[test]
1527 fn hex_preview_empty() {
1528 assert_eq!(hex_preview(&[], 10), "");
1529 }
1530
1531 #[test]
1534 fn hex_dump_single_row() {
1535 let result = hex_dump(&[0x41, 0x42], 100);
1536 assert!(result.starts_with("0000: "));
1537 assert!(result.contains("41 42"));
1538 }
1539
1540 #[test]
1541 fn hex_dump_multi_row() {
1542 let data: Vec<u8> = (0..20).collect();
1543 let result = hex_dump(&data, 100);
1544 assert!(result.contains("0000: "));
1545 assert!(result.contains("0010: ")); }
1547
1548 #[test]
1549 fn hex_dump_truncated() {
1550 let data: Vec<u8> = (0..100).collect();
1551 let result = hex_dump(&data, 32);
1552 assert!(result.contains("(truncated)"));
1553 }
1554
1555 #[test]
1556 fn hex_dump_empty() {
1557 let result = hex_dump(&[], 100);
1558 assert!(result.is_empty());
1559 }
1560
1561 #[test]
1564 fn printable_dump_ascii() {
1565 let result = printable_dump(b"Hello", 100);
1566 assert!(result.contains("Hello"));
1567 }
1568
1569 #[test]
1570 fn printable_dump_replaces_control_chars() {
1571 let result = printable_dump(&[0x01, 0x02, 0x1B], 100);
1572 assert!(result.contains("..."));
1574 }
1575
1576 #[test]
1577 fn printable_dump_truncated() {
1578 let data: Vec<u8> = (0..100).collect();
1579 let result = printable_dump(&data, 32);
1580 assert!(result.contains("(truncated)"));
1581 }
1582
1583 #[test]
1586 fn pty_config_defaults() {
1587 let config = PtyConfig::default();
1588 assert_eq!(config.cols, 80);
1589 assert_eq!(config.rows, 24);
1590 assert_eq!(config.term.as_deref(), Some("xterm-256color"));
1591 assert!(config.env.is_empty());
1592 assert!(config.test_name.is_none());
1593 assert!(config.log_events);
1594 }
1595
1596 #[test]
1597 fn pty_config_with_size() {
1598 let config = PtyConfig::default().with_size(120, 40);
1599 assert_eq!(config.cols, 120);
1600 assert_eq!(config.rows, 40);
1601 }
1602
1603 #[test]
1604 fn pty_config_with_term() {
1605 let config = PtyConfig::default().with_term("dumb");
1606 assert_eq!(config.term.as_deref(), Some("dumb"));
1607 }
1608
1609 #[test]
1610 fn pty_config_with_env() {
1611 let config = PtyConfig::default()
1612 .with_env("FOO", "bar")
1613 .with_env("BAZ", "qux");
1614 assert_eq!(config.env.len(), 2);
1615 assert_eq!(config.env[0], ("FOO".to_string(), "bar".to_string()));
1616 assert_eq!(config.env[1], ("BAZ".to_string(), "qux".to_string()));
1617 }
1618
1619 #[test]
1620 fn pty_config_with_test_name() {
1621 let config = PtyConfig::default().with_test_name("my_test");
1622 assert_eq!(config.test_name.as_deref(), Some("my_test"));
1623 }
1624
1625 #[test]
1626 fn pty_config_logging_disabled() {
1627 let config = PtyConfig::default().logging(false);
1628 assert!(!config.log_events);
1629 }
1630
1631 #[test]
1632 fn pty_config_builder_chaining() {
1633 let config = PtyConfig::default()
1634 .with_size(132, 50)
1635 .with_term("xterm")
1636 .with_env("KEY", "val")
1637 .with_test_name("chain_test")
1638 .logging(false);
1639 assert_eq!(config.cols, 132);
1640 assert_eq!(config.rows, 50);
1641 assert_eq!(config.term.as_deref(), Some("xterm"));
1642 assert_eq!(config.env.len(), 1);
1643 assert_eq!(config.test_name.as_deref(), Some("chain_test"));
1644 assert!(!config.log_events);
1645 }
1646
1647 #[test]
1650 fn read_until_options_defaults() {
1651 let opts = ReadUntilOptions::default();
1652 assert_eq!(opts.timeout, Duration::from_secs(5));
1653 assert_eq!(opts.max_retries, 0);
1654 assert_eq!(opts.retry_delay, Duration::from_millis(100));
1655 assert_eq!(opts.min_bytes, 0);
1656 }
1657
1658 #[test]
1659 fn read_until_options_with_timeout() {
1660 let opts = ReadUntilOptions::with_timeout(Duration::from_secs(10));
1661 assert_eq!(opts.timeout, Duration::from_secs(10));
1662 assert_eq!(opts.max_retries, 0); }
1664
1665 #[test]
1666 fn read_until_options_builder_chaining() {
1667 let opts = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1668 .retries(5)
1669 .retry_delay(Duration::from_millis(50))
1670 .min_bytes(100);
1671 assert_eq!(opts.timeout, Duration::from_secs(3));
1672 assert_eq!(opts.max_retries, 5);
1673 assert_eq!(opts.retry_delay, Duration::from_millis(50));
1674 assert_eq!(opts.min_bytes, 100);
1675 }
1676
1677 #[test]
1680 fn is_transient_error_would_block() {
1681 let err = io::Error::new(io::ErrorKind::WouldBlock, "test");
1682 assert!(is_transient_error(&err));
1683 }
1684
1685 #[test]
1686 fn is_transient_error_interrupted() {
1687 let err = io::Error::new(io::ErrorKind::Interrupted, "test");
1688 assert!(is_transient_error(&err));
1689 }
1690
1691 #[test]
1692 fn is_transient_error_timed_out() {
1693 let err = io::Error::new(io::ErrorKind::TimedOut, "test");
1694 assert!(is_transient_error(&err));
1695 }
1696
1697 #[test]
1698 fn is_transient_error_not_found() {
1699 let err = io::Error::new(io::ErrorKind::NotFound, "test");
1700 assert!(!is_transient_error(&err));
1701 }
1702
1703 #[test]
1704 fn is_transient_error_connection_refused() {
1705 let err = io::Error::new(io::ErrorKind::ConnectionRefused, "test");
1706 assert!(!is_transient_error(&err));
1707 }
1708
1709 #[test]
1712 fn cleanup_strict_all_true() {
1713 let strict = CleanupExpectations::strict();
1714 assert!(strict.sgr_reset);
1715 assert!(strict.show_cursor);
1716 assert!(strict.alt_screen);
1717 assert!(strict.mouse);
1718 assert!(strict.bracketed_paste);
1719 assert!(strict.focus_events);
1720 assert!(strict.kitty_keyboard);
1721 }
1722
1723 #[test]
1724 fn cleanup_for_session_matches_options() {
1725 let options = SessionOptions {
1726 alternate_screen: true,
1727 mouse_capture: false,
1728 bracketed_paste: true,
1729 focus_events: false,
1730 kitty_keyboard: true,
1731 intercept_signals: true,
1732 };
1733 let expectations = CleanupExpectations::for_session(&options);
1734 assert!(!expectations.sgr_reset); assert!(expectations.show_cursor); assert!(expectations.alt_screen);
1737 assert!(!expectations.mouse);
1738 assert!(expectations.bracketed_paste);
1739 assert!(!expectations.focus_events);
1740 assert!(expectations.kitty_keyboard);
1741 }
1742
1743 #[test]
1744 fn cleanup_for_session_all_disabled() {
1745 let options = SessionOptions {
1746 alternate_screen: false,
1747 mouse_capture: false,
1748 bracketed_paste: false,
1749 focus_events: false,
1750 kitty_keyboard: false,
1751 intercept_signals: true,
1752 };
1753 let expectations = CleanupExpectations::for_session(&options);
1754 assert!(expectations.show_cursor); assert!(!expectations.alt_screen);
1756 assert!(!expectations.mouse);
1757 assert!(!expectations.bracketed_paste);
1758 assert!(!expectations.focus_events);
1759 assert!(!expectations.kitty_keyboard);
1760 }
1761
1762 #[test]
1765 fn assert_restored_with_alt_sequence_variants() {
1766 let output1 = b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1768 assert_terminal_restored(output1, &CleanupExpectations::strict())
1769 .expect("terminal cleanup assertions failed");
1770
1771 let output2 = b"\x1b[0m\x1b[?25h\x1b[?1047l\x1b[?1000;1002l\x1b[?2004l\x1b[?1004l\x1b[<u";
1772 assert_terminal_restored(output2, &CleanupExpectations::strict())
1773 .expect("terminal cleanup assertions failed");
1774 }
1775
1776 #[test]
1777 fn assert_restored_sgr_reset_variant() {
1778 let output = b"\x1b[m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1780 assert_terminal_restored(output, &CleanupExpectations::strict())
1781 .expect("terminal cleanup assertions failed");
1782 }
1783
1784 #[test]
1785 fn assert_restored_partial_expectations() {
1786 let expectations = CleanupExpectations {
1788 sgr_reset: false,
1789 show_cursor: true,
1790 alt_screen: false,
1791 mouse: false,
1792 bracketed_paste: false,
1793 focus_events: false,
1794 kitty_keyboard: false,
1795 };
1796 assert_terminal_restored(b"\x1b[?25h", &expectations)
1797 .expect("terminal cleanup assertions failed");
1798 }
1799
1800 #[test]
1803 fn sequence_constants_are_nonempty() {
1804 assert!(!SGR_RESET_SEQS.is_empty());
1805 assert!(!CURSOR_SHOW_SEQS.is_empty());
1806 assert!(!ALT_SCREEN_EXIT_SEQS.is_empty());
1807 assert!(!MOUSE_DISABLE_SEQS.is_empty());
1808 assert!(!BRACKETED_PASTE_DISABLE_SEQS.is_empty());
1809 assert!(!FOCUS_DISABLE_SEQS.is_empty());
1810 assert!(!KITTY_DISABLE_SEQS.is_empty());
1811 }
1812
1813 #[test]
1814 fn deadline_after_rejects_unrepresentable_timeout() {
1815 let error = deadline_after(Duration::MAX, "test operation")
1816 .expect_err("oversized timeouts should not panic while building a deadline");
1817
1818 assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
1819 assert!(
1820 error.to_string().contains("test operation"),
1821 "error should name the operation that rejected the timeout"
1822 );
1823 }
1824
1825 #[test]
1826 fn deadline_after_accepts_normal_timeout() {
1827 let before = Instant::now();
1828 let deadline = deadline_after(Duration::from_secs(60), "test operation")
1829 .expect("normal timeout should produce a deadline");
1830
1831 assert!(deadline >= before);
1832 }
1833
1834 #[cfg(unix)]
1835 #[test]
1836 fn drop_does_not_block_when_background_process_keeps_pty_open() {
1837 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1838 let (done_tx, done_rx) = mpsc::channel();
1839 let drop_thread = thread::spawn(move || {
1840 let mut cmd = CommandBuilder::new(&shell);
1841 cmd.arg("-c");
1842 cmd.arg("sleep 1 >/dev/null 2>&1 &");
1843 let session =
1844 spawn_command(PtyConfig::default().logging(false), cmd).expect("spawn session");
1845 drop(session);
1846 done_tx.send(()).expect("signal drop completion");
1847 });
1848
1849 assert!(
1850 done_rx.recv_timeout(Duration::from_millis(400)).is_ok(),
1851 "PtySession drop should not wait for background descendants to close the PTY"
1852 );
1853 drop_thread.join().expect("drop thread join");
1854 }
1855
1856 #[cfg(unix)]
1857 #[test]
1858 fn send_input_times_out_when_child_does_not_drain_stdin() {
1859 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1860 let mut cmd = CommandBuilder::new(&shell);
1861 cmd.arg("-c");
1862 cmd.arg("sleep 5");
1863 let mut session = spawn_command(
1864 PtyConfig::default()
1865 .logging(false)
1866 .with_input_write_timeout(Duration::from_millis(100)),
1867 cmd,
1868 )
1869 .expect("spawn session");
1870
1871 let payload = vec![b'x'; 8 * 1024 * 1024];
1872 let start = Instant::now();
1873 let err = session
1874 .send_input(&payload)
1875 .expect_err("send_input should time out when the child never reads stdin");
1876 assert_eq!(err.kind(), io::ErrorKind::TimedOut);
1877 assert!(
1878 start.elapsed() < Duration::from_secs(2),
1879 "send_input should fail promptly instead of hanging"
1880 );
1881 }
1882}