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 = Instant::now() + options.timeout;
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 = Instant::now() + timeout;
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 detach_join(handle: thread::JoinHandle<()>, thread_name: &str) {
792 let _ = thread::Builder::new()
793 .name(thread_name.to_string())
794 .spawn(move || {
795 let _ = handle.join();
796 });
797}
798
799pub fn assert_terminal_restored(
801 output: &[u8],
802 expectations: &CleanupExpectations,
803) -> Result<(), String> {
804 let mut failures = Vec::new();
805
806 if expectations.sgr_reset && !contains_any(output, SGR_RESET_SEQS) {
807 failures.push("Missing SGR reset (CSI 0 m)");
808 }
809 if expectations.show_cursor && !contains_any(output, CURSOR_SHOW_SEQS) {
810 failures.push("Missing cursor show (CSI ? 25 h)");
811 }
812 if expectations.alt_screen && !contains_any(output, ALT_SCREEN_EXIT_SEQS) {
813 failures.push("Missing alt-screen exit (CSI ? 1049 l)");
814 }
815 if expectations.mouse && !contains_any(output, MOUSE_DISABLE_SEQS) {
816 failures.push("Missing mouse disable (CSI ? 1000... l)");
817 }
818 if expectations.bracketed_paste && !contains_any(output, BRACKETED_PASTE_DISABLE_SEQS) {
819 failures.push("Missing bracketed paste disable (CSI ? 2004 l)");
820 }
821 if expectations.focus_events && !contains_any(output, FOCUS_DISABLE_SEQS) {
822 failures.push("Missing focus disable (CSI ? 1004 l)");
823 }
824 if expectations.kitty_keyboard && !contains_any(output, KITTY_DISABLE_SEQS) {
825 failures.push("Missing kitty keyboard disable (CSI < u)");
826 }
827
828 if failures.is_empty() {
829 log_event(true, "PTY_TEST_PASS", "terminal cleanup sequences verified");
830 return Ok(());
831 }
832
833 for failure in &failures {
834 log_event(true, "PTY_FAILURE_REASON", *failure);
835 }
836
837 log_event(true, "PTY_OUTPUT_DUMP", "hex:");
838 for line in hex_dump(output, 4096).lines() {
839 log_event(true, "PTY_OUTPUT_DUMP", line);
840 }
841
842 log_event(true, "PTY_OUTPUT_DUMP", "printable:");
843 for line in printable_dump(output, 4096).lines() {
844 log_event(true, "PTY_OUTPUT_DUMP", line);
845 }
846
847 Err(failures.join("; "))
848}
849
850fn log_event(enabled: bool, event: &str, detail: impl fmt::Display) {
851 if !enabled {
852 return;
853 }
854
855 let timestamp = timestamp_rfc3339();
856 eprintln!("[{}] {}: {}", timestamp, event, detail);
857}
858
859fn timestamp_rfc3339() -> String {
860 time::OffsetDateTime::now_utc()
861 .format(&time::format_description::well_known::Rfc3339)
862 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
863}
864
865fn hex_preview(bytes: &[u8], limit: usize) -> String {
866 let mut out = String::new();
867 for b in bytes.iter().take(limit) {
868 out.push_str(&format!("{:02x}", b));
869 }
870 if bytes.len() > limit {
871 out.push_str("..");
872 }
873 out
874}
875
876fn hex_dump(bytes: &[u8], limit: usize) -> String {
877 let mut out = String::new();
878 let slice = bytes.get(0..limit).unwrap_or(bytes);
879
880 for (row, chunk) in slice.chunks(16).enumerate() {
881 let offset = row * 16;
882 out.push_str(&format!("{:04x}: ", offset));
883 for b in chunk {
884 out.push_str(&format!("{:02x} ", b));
885 }
886 out.push('\n');
887 }
888
889 if bytes.len() > limit {
890 out.push_str("... (truncated)\n");
891 }
892
893 out
894}
895
896fn printable_dump(bytes: &[u8], limit: usize) -> String {
897 let mut out = String::new();
898 let slice = bytes.get(0..limit).unwrap_or(bytes);
899
900 for (row, chunk) in slice.chunks(16).enumerate() {
901 let offset = row * 16;
902 out.push_str(&format!("{:04x}: ", offset));
903 for b in chunk {
904 let ch = if b.is_ascii_graphic() || *b == b' ' {
905 *b as char
906 } else {
907 '.'
908 };
909 out.push(ch);
910 }
911 out.push('\n');
912 }
913
914 if bytes.len() > limit {
915 out.push_str("... (truncated)\n");
916 }
917
918 out
919}
920
921fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
922 if needle.is_empty() {
923 return Some(0);
924 }
925 haystack
926 .windows(needle.len())
927 .position(|window| window == needle)
928}
929
930fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
931 needles
932 .iter()
933 .any(|needle| find_subsequence(haystack, needle).is_some())
934}
935
936fn portable_pty_error<E: fmt::Display>(err: E) -> io::Error {
937 io::Error::other(err.to_string())
938}
939
940fn is_transient_error(err: &io::Error) -> bool {
942 matches!(
943 err.kind(),
944 io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted | io::ErrorKind::TimedOut
945 )
946}
947
948const SGR_RESET_SEQS: &[&[u8]] = &[b"\x1b[0m", b"\x1b[m"];
949const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
950const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
951const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
952 b"\x1b[?1000l\x1b[?1002l\x1b[?1006l",
953 b"\x1b[?1002l\x1b[?1006l",
954 b"\x1b[?1000;1002;1006l",
955 b"\x1b[?1000;1002l",
956 b"\x1b[?1000l",
957 b"\x1b[?1002l",
958 b"\x1b[?1006l",
959];
960const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
961const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
962const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 #[test]
969 fn normalize_line_input_appends_carriage_return() {
970 assert_eq!(normalize_line_input(b"echo hi"), b"echo hi\r\n");
971 }
972
973 #[test]
974 fn normalize_line_input_replaces_trailing_line_feed() {
975 assert_eq!(normalize_line_input(b"echo hi\n"), b"echo hi\r\n");
976 }
977
978 #[test]
979 fn normalize_line_input_preserves_existing_carriage_return() {
980 assert_eq!(normalize_line_input(b"echo hi\r"), b"echo hi\r\n");
981 }
982 #[cfg(unix)]
983 use ftui_core::terminal_session::{TerminalSession, best_effort_cleanup_for_exit};
984
985 #[test]
986 fn cleanup_expectations_match_sequences() {
987 let output =
988 b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000;1002;1006l\x1b[?2004l\x1b[?1004l\x1b[<u";
989 assert_terminal_restored(output, &CleanupExpectations::strict())
990 .expect("terminal cleanup assertions failed");
991 }
992
993 #[test]
994 #[should_panic]
995 fn cleanup_expectations_fail_when_missing() {
996 let output = b"\x1b[?25h";
997 assert_terminal_restored(output, &CleanupExpectations::strict())
998 .expect("terminal cleanup assertions failed");
999 }
1000
1001 #[cfg(unix)]
1002 #[test]
1003 fn spawn_command_captures_output() {
1004 let config = PtyConfig::default().logging(false);
1005
1006 let mut cmd = CommandBuilder::new("sh");
1007 cmd.args(["-c", "printf hello-pty"]);
1008
1009 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1010
1011 let _status = session.wait().expect("wait should succeed");
1012 let output = session
1016 .read_until(b"hello-pty", Duration::from_secs(5))
1017 .expect("expected PTY output to contain test string");
1018 assert!(
1019 output
1020 .windows(b"hello-pty".len())
1021 .any(|w| w == b"hello-pty"),
1022 "expected PTY output to contain test string"
1023 );
1024 }
1025
1026 #[cfg(unix)]
1027 #[test]
1028 fn read_until_with_options_min_bytes() {
1029 let config = PtyConfig::default().logging(false);
1030
1031 let mut cmd = CommandBuilder::new("sh");
1032 cmd.args(["-c", "printf 'short'; sleep 0.05; printf 'longer-output'"]);
1033
1034 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1035
1036 let options = ReadUntilOptions::with_timeout(Duration::from_secs(5)).min_bytes(10);
1038
1039 let output = session
1040 .read_until_with_options(b"output", options)
1041 .expect("expected to find pattern with min_bytes");
1042
1043 assert!(
1044 output.len() >= 10,
1045 "expected at least 10 bytes, got {}",
1046 output.len()
1047 );
1048 assert!(
1049 output.windows(b"output".len()).any(|w| w == b"output"),
1050 "expected pattern 'output' in captured data"
1051 );
1052 }
1053
1054 #[cfg(unix)]
1055 #[test]
1056 fn read_until_with_options_retries_on_timeout_then_succeeds() {
1057 let config = PtyConfig::default().logging(false);
1058
1059 let mut cmd = CommandBuilder::new("sh");
1060 cmd.args(["-c", "sleep 0.1; printf done"]);
1061
1062 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1063
1064 let options = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1066 .retries(3)
1067 .retry_delay(Duration::from_millis(50));
1068
1069 let output = session
1070 .read_until_with_options(b"done", options)
1071 .expect("should succeed with retries");
1072
1073 assert!(
1074 output.windows(b"done".len()).any(|w| w == b"done"),
1075 "expected 'done' in output"
1076 );
1077 }
1078
1079 #[cfg(unix)]
1082 #[test]
1083 fn large_output_fully_captured() {
1084 let config = PtyConfig::default().logging(false);
1085
1086 let mut cmd = CommandBuilder::new("sh");
1088 cmd.args(["-c", "dd if=/dev/zero bs=1024 count=64 2>/dev/null | od -v"]);
1089
1090 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1091
1092 let _status = session
1093 .wait_and_drain(Duration::from_secs(5))
1094 .expect("wait_and_drain");
1095
1096 let output = session.output();
1098 assert!(
1099 output.len() > 50_000,
1100 "expected >50KB of output, got {} bytes",
1101 output.len()
1102 );
1103 }
1104
1105 #[cfg(unix)]
1106 #[test]
1107 fn late_output_after_exit_captured() {
1108 let config = PtyConfig::default().logging(false);
1109
1110 let mut cmd = CommandBuilder::new("sh");
1112 cmd.args([
1113 "-c",
1114 "printf 'start\\n'; sleep 0.05; printf 'middle\\n'; sleep 0.05; printf 'end\\n'",
1115 ]);
1116
1117 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1118
1119 let _status = session.wait().expect("wait should succeed");
1121
1122 let _drained = session
1124 .drain_remaining(Duration::from_secs(2))
1125 .expect("drain_remaining should succeed");
1126
1127 let output = session.output();
1128 let output_str = String::from_utf8_lossy(output);
1129
1130 assert!(
1132 output_str.contains("start"),
1133 "missing 'start' in output: {output_str:?}"
1134 );
1135 assert!(
1136 output_str.contains("middle"),
1137 "missing 'middle' in output: {output_str:?}"
1138 );
1139 assert!(
1140 output_str.contains("end"),
1141 "missing 'end' in output: {output_str:?}"
1142 );
1143
1144 let start_pos = output_str.find("start").unwrap();
1146 let middle_pos = output_str.find("middle").unwrap();
1147 let end_pos = output_str.find("end").unwrap();
1148 assert!(
1149 start_pos < middle_pos && middle_pos < end_pos,
1150 "output not in expected order: start={start_pos}, middle={middle_pos}, end={end_pos}"
1151 );
1152
1153 let drained_again = session
1155 .drain_remaining(Duration::from_millis(100))
1156 .expect("second drain should succeed");
1157 assert_eq!(drained_again, 0, "second drain should return 0");
1158 }
1159
1160 #[cfg(unix)]
1161 #[test]
1162 fn wait_and_drain_captures_all() {
1163 let config = PtyConfig::default().logging(false);
1164
1165 let mut cmd = CommandBuilder::new("sh");
1166 cmd.args([
1167 "-c",
1168 "for i in 1 2 3 4 5; do printf \"line$i\\n\"; sleep 0.02; done",
1169 ]);
1170
1171 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1172
1173 let status = session
1175 .wait_and_drain(Duration::from_secs(2))
1176 .expect("wait_and_drain should succeed");
1177
1178 assert!(status.success(), "child should succeed");
1179
1180 let output = session.output();
1181 let output_str = String::from_utf8_lossy(output);
1182
1183 for i in 1..=5 {
1185 assert!(
1186 output_str.contains(&format!("line{i}")),
1187 "missing 'line{i}' in output: {output_str:?}"
1188 );
1189 }
1190 }
1191
1192 #[cfg(unix)]
1193 #[test]
1194 fn wait_and_drain_large_output_ordered() {
1195 let config = PtyConfig::default().logging(false);
1196
1197 let mut cmd = CommandBuilder::new("sh");
1198 cmd.args([
1199 "-c",
1200 "i=1; while [ $i -le 1200 ]; do printf \"line%04d\\n\" $i; i=$((i+1)); done",
1201 ]);
1202
1203 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1204
1205 let status = session
1206 .wait_and_drain(Duration::from_secs(3))
1207 .expect("wait_and_drain should succeed");
1208
1209 assert!(status.success(), "child should succeed");
1210
1211 let output = session.output();
1212 let output_str = String::from_utf8_lossy(output);
1213 let lines: Vec<&str> = output_str.lines().collect();
1214
1215 assert_eq!(
1216 lines.len(),
1217 1200,
1218 "expected 1200 lines, got {}",
1219 lines.len()
1220 );
1221 assert_eq!(lines.first().copied(), Some("line0001"));
1222 assert_eq!(lines.last().copied(), Some("line1200"));
1223 }
1224
1225 #[cfg(unix)]
1226 #[test]
1227 fn drain_remaining_respects_eof() {
1228 let config = PtyConfig::default().logging(false);
1229
1230 let mut cmd = CommandBuilder::new("sh");
1231 cmd.args(["-c", "printf 'quick'"]);
1232
1233 let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1234
1235 let _ = session
1237 .wait_and_drain(Duration::from_secs(2))
1238 .expect("wait_and_drain");
1239
1240 assert!(session.eof, "should be at EOF after wait_and_drain");
1242
1243 let result = session
1245 .drain_remaining(Duration::from_secs(1))
1246 .expect("drain");
1247 assert_eq!(result, 0, "drain after EOF should return 0");
1248 }
1249
1250 #[cfg(unix)]
1251 #[test]
1252 fn pty_terminal_session_cleanup() {
1253 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1254 cmd.args([
1255 "--exact",
1256 "tests::pty_terminal_session_cleanup_child",
1257 "--nocapture",
1258 ]);
1259 cmd.env("FTUI_PTY_CHILD", "1");
1260 cmd.env("FTUI_TEST_PROFILE", "modern");
1261 cmd.env("TERM", "xterm-256color");
1262
1263 let config = PtyConfig::default()
1264 .with_test_name("terminal_session_cleanup")
1265 .logging(false);
1266 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1267
1268 let status = session.wait().expect("wait for child");
1269 assert!(status.success(), "child test failed: {:?}", status);
1270
1271 let _ = session
1272 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1273 .expect("expected cursor show sequence");
1274 let _ = session
1275 .drain_remaining(Duration::from_secs(1))
1276 .expect("drain remaining");
1277 let output = session.output();
1278
1279 let options = SessionOptions {
1280 alternate_screen: true,
1281 mouse_capture: true,
1282 bracketed_paste: true,
1283 focus_events: true,
1284 kitty_keyboard: true,
1285 intercept_signals: true,
1286 };
1287 let expectations = CleanupExpectations::for_session(&options);
1288 assert_terminal_restored(output, &expectations)
1289 .expect("terminal cleanup assertions failed");
1290 }
1291
1292 #[cfg(unix)]
1293 #[test]
1294 fn pty_terminal_session_cleanup_child() {
1295 if std::env::var("FTUI_PTY_CHILD").as_deref() != Ok("1") {
1296 return;
1297 }
1298
1299 let options = SessionOptions {
1300 alternate_screen: true,
1301 mouse_capture: true,
1302 bracketed_paste: true,
1303 focus_events: true,
1304 kitty_keyboard: true,
1305 intercept_signals: true,
1306 };
1307
1308 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1309 }
1310
1311 #[cfg(unix)]
1312 #[test]
1313 fn pty_terminal_session_cleanup_on_panic() {
1314 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1315 cmd.args([
1316 "--exact",
1317 "tests::pty_terminal_session_cleanup_panic_child",
1318 "--nocapture",
1319 ]);
1320 cmd.env("FTUI_PTY_PANIC_CHILD", "1");
1321 cmd.env("FTUI_TEST_PROFILE", "modern");
1322 cmd.env("TERM", "xterm-256color");
1323
1324 let config = PtyConfig::default()
1325 .with_test_name("terminal_session_cleanup_panic")
1326 .logging(false);
1327 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1328
1329 let status = session.wait().expect("wait for child");
1330 assert!(
1331 !status.success(),
1332 "panic child should exit with failure status"
1333 );
1334
1335 let _ = session
1336 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1337 .expect("expected cursor show sequence");
1338 let _ = session
1339 .drain_remaining(Duration::from_secs(1))
1340 .expect("drain remaining");
1341 let output = session.output();
1342
1343 let options = SessionOptions {
1344 alternate_screen: true,
1345 mouse_capture: true,
1346 bracketed_paste: true,
1347 focus_events: true,
1348 kitty_keyboard: true,
1349 intercept_signals: true,
1350 };
1351 let expectations = CleanupExpectations::for_session(&options);
1352 assert_terminal_restored(output, &expectations)
1353 .expect("terminal cleanup assertions failed");
1354 }
1355
1356 #[cfg(unix)]
1357 #[test]
1358 fn pty_terminal_session_cleanup_panic_child() {
1359 if std::env::var("FTUI_PTY_PANIC_CHILD").as_deref() != Ok("1") {
1360 return;
1361 }
1362
1363 let options = SessionOptions {
1364 alternate_screen: true,
1365 mouse_capture: true,
1366 bracketed_paste: true,
1367 focus_events: true,
1368 kitty_keyboard: true,
1369 intercept_signals: true,
1370 };
1371
1372 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1373 std::panic::panic_any("intentional panic to verify cleanup on unwind");
1374 }
1375
1376 #[cfg(unix)]
1377 #[test]
1378 fn pty_terminal_session_cleanup_on_exit() {
1379 let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1380 cmd.args([
1381 "--exact",
1382 "tests::pty_terminal_session_cleanup_exit_child",
1383 "--nocapture",
1384 ]);
1385 cmd.env("FTUI_PTY_EXIT_CHILD", "1");
1386 cmd.env("FTUI_TEST_PROFILE", "modern");
1387 cmd.env("TERM", "xterm-256color");
1388
1389 let config = PtyConfig::default()
1390 .with_test_name("terminal_session_cleanup_exit")
1391 .logging(false);
1392 let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1393
1394 let status = session.wait().expect("wait for child");
1395 assert!(status.success(), "exit child should succeed: {:?}", status);
1396
1397 let _ = session
1398 .read_until(b"\x1b[?25h", Duration::from_secs(5))
1399 .expect("expected cursor show sequence");
1400 let _ = session
1401 .drain_remaining(Duration::from_secs(1))
1402 .expect("drain remaining");
1403 let output = session.output();
1404
1405 let options = SessionOptions {
1406 alternate_screen: true,
1407 mouse_capture: true,
1408 bracketed_paste: true,
1409 focus_events: true,
1410 kitty_keyboard: true,
1411 intercept_signals: true,
1412 };
1413 let expectations = CleanupExpectations::for_session(&options);
1414 assert_terminal_restored(output, &expectations)
1415 .expect("terminal cleanup assertions failed");
1416 }
1417
1418 #[cfg(unix)]
1419 #[test]
1420 fn pty_terminal_session_cleanup_exit_child() {
1421 if std::env::var("FTUI_PTY_EXIT_CHILD").as_deref() != Ok("1") {
1422 return;
1423 }
1424
1425 let options = SessionOptions {
1426 alternate_screen: true,
1427 mouse_capture: true,
1428 bracketed_paste: true,
1429 focus_events: true,
1430 kitty_keyboard: true,
1431 intercept_signals: true,
1432 };
1433
1434 let _session = TerminalSession::new(options).expect("TerminalSession::new");
1435 best_effort_cleanup_for_exit();
1436 std::process::exit(0);
1437 }
1438
1439 #[test]
1442 fn find_subsequence_empty_needle() {
1443 assert_eq!(find_subsequence(b"anything", b""), Some(0));
1444 }
1445
1446 #[test]
1447 fn find_subsequence_empty_haystack() {
1448 assert_eq!(find_subsequence(b"", b"x"), None);
1449 }
1450
1451 #[test]
1452 fn find_subsequence_found_at_start() {
1453 assert_eq!(find_subsequence(b"hello world", b"hello"), Some(0));
1454 }
1455
1456 #[test]
1457 fn find_subsequence_found_in_middle() {
1458 assert_eq!(find_subsequence(b"hello world", b"o w"), Some(4));
1459 }
1460
1461 #[test]
1462 fn find_subsequence_found_at_end() {
1463 assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
1464 }
1465
1466 #[test]
1467 fn find_subsequence_not_found() {
1468 assert_eq!(find_subsequence(b"hello world", b"xyz"), None);
1469 }
1470
1471 #[test]
1472 fn find_subsequence_needle_longer_than_haystack() {
1473 assert_eq!(find_subsequence(b"ab", b"abcdef"), None);
1474 }
1475
1476 #[test]
1477 fn find_subsequence_exact_match() {
1478 assert_eq!(find_subsequence(b"abc", b"abc"), Some(0));
1479 }
1480
1481 #[test]
1484 fn contains_any_finds_first_match() {
1485 assert!(contains_any(b"\x1b[0m test", &[b"\x1b[0m", b"\x1b[m"]));
1486 }
1487
1488 #[test]
1489 fn contains_any_finds_second_match() {
1490 assert!(contains_any(b"\x1b[m test", &[b"\x1b[0m", b"\x1b[m"]));
1491 }
1492
1493 #[test]
1494 fn contains_any_no_match() {
1495 assert!(!contains_any(b"plain text", &[b"\x1b[0m", b"\x1b[m"]));
1496 }
1497
1498 #[test]
1499 fn contains_any_empty_needles() {
1500 assert!(!contains_any(b"test", &[]));
1501 }
1502
1503 #[test]
1506 fn hex_preview_basic() {
1507 let result = hex_preview(&[0x41, 0x42, 0x43], 10);
1508 assert_eq!(result, "414243");
1509 }
1510
1511 #[test]
1512 fn hex_preview_truncated() {
1513 let result = hex_preview(&[0x00, 0x01, 0x02, 0x03, 0x04], 3);
1514 assert_eq!(result, "000102..");
1515 }
1516
1517 #[test]
1518 fn hex_preview_empty() {
1519 assert_eq!(hex_preview(&[], 10), "");
1520 }
1521
1522 #[test]
1525 fn hex_dump_single_row() {
1526 let result = hex_dump(&[0x41, 0x42], 100);
1527 assert!(result.starts_with("0000: "));
1528 assert!(result.contains("41 42"));
1529 }
1530
1531 #[test]
1532 fn hex_dump_multi_row() {
1533 let data: Vec<u8> = (0..20).collect();
1534 let result = hex_dump(&data, 100);
1535 assert!(result.contains("0000: "));
1536 assert!(result.contains("0010: ")); }
1538
1539 #[test]
1540 fn hex_dump_truncated() {
1541 let data: Vec<u8> = (0..100).collect();
1542 let result = hex_dump(&data, 32);
1543 assert!(result.contains("(truncated)"));
1544 }
1545
1546 #[test]
1547 fn hex_dump_empty() {
1548 let result = hex_dump(&[], 100);
1549 assert!(result.is_empty());
1550 }
1551
1552 #[test]
1555 fn printable_dump_ascii() {
1556 let result = printable_dump(b"Hello", 100);
1557 assert!(result.contains("Hello"));
1558 }
1559
1560 #[test]
1561 fn printable_dump_replaces_control_chars() {
1562 let result = printable_dump(&[0x01, 0x02, 0x1B], 100);
1563 assert!(result.contains("..."));
1565 }
1566
1567 #[test]
1568 fn printable_dump_truncated() {
1569 let data: Vec<u8> = (0..100).collect();
1570 let result = printable_dump(&data, 32);
1571 assert!(result.contains("(truncated)"));
1572 }
1573
1574 #[test]
1577 fn pty_config_defaults() {
1578 let config = PtyConfig::default();
1579 assert_eq!(config.cols, 80);
1580 assert_eq!(config.rows, 24);
1581 assert_eq!(config.term.as_deref(), Some("xterm-256color"));
1582 assert!(config.env.is_empty());
1583 assert!(config.test_name.is_none());
1584 assert!(config.log_events);
1585 }
1586
1587 #[test]
1588 fn pty_config_with_size() {
1589 let config = PtyConfig::default().with_size(120, 40);
1590 assert_eq!(config.cols, 120);
1591 assert_eq!(config.rows, 40);
1592 }
1593
1594 #[test]
1595 fn pty_config_with_term() {
1596 let config = PtyConfig::default().with_term("dumb");
1597 assert_eq!(config.term.as_deref(), Some("dumb"));
1598 }
1599
1600 #[test]
1601 fn pty_config_with_env() {
1602 let config = PtyConfig::default()
1603 .with_env("FOO", "bar")
1604 .with_env("BAZ", "qux");
1605 assert_eq!(config.env.len(), 2);
1606 assert_eq!(config.env[0], ("FOO".to_string(), "bar".to_string()));
1607 assert_eq!(config.env[1], ("BAZ".to_string(), "qux".to_string()));
1608 }
1609
1610 #[test]
1611 fn pty_config_with_test_name() {
1612 let config = PtyConfig::default().with_test_name("my_test");
1613 assert_eq!(config.test_name.as_deref(), Some("my_test"));
1614 }
1615
1616 #[test]
1617 fn pty_config_logging_disabled() {
1618 let config = PtyConfig::default().logging(false);
1619 assert!(!config.log_events);
1620 }
1621
1622 #[test]
1623 fn pty_config_builder_chaining() {
1624 let config = PtyConfig::default()
1625 .with_size(132, 50)
1626 .with_term("xterm")
1627 .with_env("KEY", "val")
1628 .with_test_name("chain_test")
1629 .logging(false);
1630 assert_eq!(config.cols, 132);
1631 assert_eq!(config.rows, 50);
1632 assert_eq!(config.term.as_deref(), Some("xterm"));
1633 assert_eq!(config.env.len(), 1);
1634 assert_eq!(config.test_name.as_deref(), Some("chain_test"));
1635 assert!(!config.log_events);
1636 }
1637
1638 #[test]
1641 fn read_until_options_defaults() {
1642 let opts = ReadUntilOptions::default();
1643 assert_eq!(opts.timeout, Duration::from_secs(5));
1644 assert_eq!(opts.max_retries, 0);
1645 assert_eq!(opts.retry_delay, Duration::from_millis(100));
1646 assert_eq!(opts.min_bytes, 0);
1647 }
1648
1649 #[test]
1650 fn read_until_options_with_timeout() {
1651 let opts = ReadUntilOptions::with_timeout(Duration::from_secs(10));
1652 assert_eq!(opts.timeout, Duration::from_secs(10));
1653 assert_eq!(opts.max_retries, 0); }
1655
1656 #[test]
1657 fn read_until_options_builder_chaining() {
1658 let opts = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1659 .retries(5)
1660 .retry_delay(Duration::from_millis(50))
1661 .min_bytes(100);
1662 assert_eq!(opts.timeout, Duration::from_secs(3));
1663 assert_eq!(opts.max_retries, 5);
1664 assert_eq!(opts.retry_delay, Duration::from_millis(50));
1665 assert_eq!(opts.min_bytes, 100);
1666 }
1667
1668 #[test]
1671 fn is_transient_error_would_block() {
1672 let err = io::Error::new(io::ErrorKind::WouldBlock, "test");
1673 assert!(is_transient_error(&err));
1674 }
1675
1676 #[test]
1677 fn is_transient_error_interrupted() {
1678 let err = io::Error::new(io::ErrorKind::Interrupted, "test");
1679 assert!(is_transient_error(&err));
1680 }
1681
1682 #[test]
1683 fn is_transient_error_timed_out() {
1684 let err = io::Error::new(io::ErrorKind::TimedOut, "test");
1685 assert!(is_transient_error(&err));
1686 }
1687
1688 #[test]
1689 fn is_transient_error_not_found() {
1690 let err = io::Error::new(io::ErrorKind::NotFound, "test");
1691 assert!(!is_transient_error(&err));
1692 }
1693
1694 #[test]
1695 fn is_transient_error_connection_refused() {
1696 let err = io::Error::new(io::ErrorKind::ConnectionRefused, "test");
1697 assert!(!is_transient_error(&err));
1698 }
1699
1700 #[test]
1703 fn cleanup_strict_all_true() {
1704 let strict = CleanupExpectations::strict();
1705 assert!(strict.sgr_reset);
1706 assert!(strict.show_cursor);
1707 assert!(strict.alt_screen);
1708 assert!(strict.mouse);
1709 assert!(strict.bracketed_paste);
1710 assert!(strict.focus_events);
1711 assert!(strict.kitty_keyboard);
1712 }
1713
1714 #[test]
1715 fn cleanup_for_session_matches_options() {
1716 let options = SessionOptions {
1717 alternate_screen: true,
1718 mouse_capture: false,
1719 bracketed_paste: true,
1720 focus_events: false,
1721 kitty_keyboard: true,
1722 intercept_signals: true,
1723 };
1724 let expectations = CleanupExpectations::for_session(&options);
1725 assert!(!expectations.sgr_reset); assert!(expectations.show_cursor); assert!(expectations.alt_screen);
1728 assert!(!expectations.mouse);
1729 assert!(expectations.bracketed_paste);
1730 assert!(!expectations.focus_events);
1731 assert!(expectations.kitty_keyboard);
1732 }
1733
1734 #[test]
1735 fn cleanup_for_session_all_disabled() {
1736 let options = SessionOptions {
1737 alternate_screen: false,
1738 mouse_capture: false,
1739 bracketed_paste: false,
1740 focus_events: false,
1741 kitty_keyboard: false,
1742 intercept_signals: true,
1743 };
1744 let expectations = CleanupExpectations::for_session(&options);
1745 assert!(expectations.show_cursor); assert!(!expectations.alt_screen);
1747 assert!(!expectations.mouse);
1748 assert!(!expectations.bracketed_paste);
1749 assert!(!expectations.focus_events);
1750 assert!(!expectations.kitty_keyboard);
1751 }
1752
1753 #[test]
1756 fn assert_restored_with_alt_sequence_variants() {
1757 let output1 = b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1759 assert_terminal_restored(output1, &CleanupExpectations::strict())
1760 .expect("terminal cleanup assertions failed");
1761
1762 let output2 = b"\x1b[0m\x1b[?25h\x1b[?1047l\x1b[?1000;1002l\x1b[?2004l\x1b[?1004l\x1b[<u";
1763 assert_terminal_restored(output2, &CleanupExpectations::strict())
1764 .expect("terminal cleanup assertions failed");
1765 }
1766
1767 #[test]
1768 fn assert_restored_sgr_reset_variant() {
1769 let output = b"\x1b[m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1771 assert_terminal_restored(output, &CleanupExpectations::strict())
1772 .expect("terminal cleanup assertions failed");
1773 }
1774
1775 #[test]
1776 fn assert_restored_partial_expectations() {
1777 let expectations = CleanupExpectations {
1779 sgr_reset: false,
1780 show_cursor: true,
1781 alt_screen: false,
1782 mouse: false,
1783 bracketed_paste: false,
1784 focus_events: false,
1785 kitty_keyboard: false,
1786 };
1787 assert_terminal_restored(b"\x1b[?25h", &expectations)
1788 .expect("terminal cleanup assertions failed");
1789 }
1790
1791 #[test]
1794 fn sequence_constants_are_nonempty() {
1795 assert!(!SGR_RESET_SEQS.is_empty());
1796 assert!(!CURSOR_SHOW_SEQS.is_empty());
1797 assert!(!ALT_SCREEN_EXIT_SEQS.is_empty());
1798 assert!(!MOUSE_DISABLE_SEQS.is_empty());
1799 assert!(!BRACKETED_PASTE_DISABLE_SEQS.is_empty());
1800 assert!(!FOCUS_DISABLE_SEQS.is_empty());
1801 assert!(!KITTY_DISABLE_SEQS.is_empty());
1802 }
1803
1804 #[cfg(unix)]
1805 #[test]
1806 fn drop_does_not_block_when_background_process_keeps_pty_open() {
1807 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1808 let (done_tx, done_rx) = mpsc::channel();
1809 let drop_thread = thread::spawn(move || {
1810 let mut cmd = CommandBuilder::new(&shell);
1811 cmd.arg("-c");
1812 cmd.arg("sleep 1 >/dev/null 2>&1 &");
1813 let session =
1814 spawn_command(PtyConfig::default().logging(false), cmd).expect("spawn session");
1815 drop(session);
1816 done_tx.send(()).expect("signal drop completion");
1817 });
1818
1819 assert!(
1820 done_rx.recv_timeout(Duration::from_millis(400)).is_ok(),
1821 "PtySession drop should not wait for background descendants to close the PTY"
1822 );
1823 drop_thread.join().expect("drop thread join");
1824 }
1825
1826 #[cfg(unix)]
1827 #[test]
1828 fn send_input_times_out_when_child_does_not_drain_stdin() {
1829 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1830 let mut cmd = CommandBuilder::new(&shell);
1831 cmd.arg("-c");
1832 cmd.arg("sleep 5");
1833 let mut session = spawn_command(
1834 PtyConfig::default()
1835 .logging(false)
1836 .with_input_write_timeout(Duration::from_millis(100)),
1837 cmd,
1838 )
1839 .expect("spawn session");
1840
1841 let payload = vec![b'x'; 8 * 1024 * 1024];
1842 let start = Instant::now();
1843 let err = session
1844 .send_input(&payload)
1845 .expect_err("send_input should time out when the child never reads stdin");
1846 assert_eq!(err.kind(), io::ErrorKind::TimedOut);
1847 assert!(
1848 start.elapsed() < Duration::from_secs(2),
1849 "send_input should fail promptly instead of hanging"
1850 );
1851 }
1852}