1use std::path::PathBuf;
4use std::sync::{Arc, Mutex, MutexGuard};
5use std::time::{Duration, Instant};
6
7use crate::api::{
8 Cell, CellColor, Cursor, EffectiveTimeouts, ErrorKind, OpenOptions, OpenResult, Operation,
9 OperationResult, PackedScreen, RunOptions, RuntimeStatus, ScreenshotResult, Size,
10 SnapshotResult, TuiTestError,
11};
12use crate::assert::color::{self, Expected};
13use crate::assert::snapshot::{self, SnapshotStatus};
14use crate::config::{self, POLL_DELAY_MS};
15use crate::input::{keys, mouse};
16use crate::logger::Logger;
17use crate::session::{Session as TerminalSession, TermState};
18use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell};
19use crate::terminal::emu::Emulator;
20use crate::terminal::locator::{self, Pattern};
21
22pub struct Engine {
23 name: String,
24 operations: Mutex<()>,
25 session: Mutex<Option<TerminalSession>>,
26 live: Arc<Mutex<Option<LiveTarget>>>,
27 interrupt: Mutex<Option<InterruptTarget>>,
28 logger: Arc<Logger>,
29 recording_path: PathBuf,
30}
31
32#[derive(Clone)]
33struct InterruptTarget {
34 pty: Arc<Mutex<crate::terminal::pty::Pty>>,
35 cancelled: Arc<std::sync::atomic::AtomicBool>,
36}
37
38struct LiveTarget {
39 state: Arc<Mutex<TermState>>,
40 shell: Option<&'static str>,
41}
42
43pub struct LiveFrame {
44 pub grid: Vec<Vec<EmuCell>>,
45 pub cursor: (u16, u16),
46 pub size: (u16, u16),
47 pub exited: Option<i32>,
48 pub shell: Option<&'static str>,
49}
50
51fn operation_summary(operation: &Operation) -> String {
54 match operation {
55 Operation::Open(options) => format!(
56 "Open {{ backend: {}, shell: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, restart: {}, timeouts: {:?}, env: <{} vars> }}",
57 options.backend.as_str(),
58 options.shell,
59 options.profile.scrollback,
60 options.cols,
61 options.rows,
62 options.cwd,
63 options.wait_ready,
64 options.restart,
65 options.timeouts,
66 options.env.len()
67 ),
68 Operation::Run(options) => format!(
69 "Run {{ backend: {}, program: {:?}, args: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, restart: {}, timeouts: {:?}, env: <{} vars> }}",
70 options.backend.as_str(),
71 options.program,
72 options.args,
73 options.profile.scrollback,
74 options.cols,
75 options.rows,
76 options.cwd,
77 options.wait_ready,
78 options.restart,
79 options.timeouts,
80 options.env.len()
81 ),
82 other => format!("{other:?}"),
83 }
84}
85
86impl Engine {
87 pub fn new(name: String, logger: Arc<Logger>, recording_path: PathBuf) -> Self {
88 Self {
89 name,
90 operations: Mutex::new(()),
91 session: Mutex::new(None),
92 live: Arc::new(Mutex::new(None)),
93 interrupt: Mutex::new(None),
94 logger,
95 recording_path,
96 }
97 }
98
99 pub fn execute(&self, operation: Operation) -> Result<OperationResult, TuiTestError> {
100 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
101 self.execute_inner(operation)
102 }))
103 .unwrap_or_else(|payload| {
104 Err(TuiTestError::internal(format!(
105 "native terminal operation panicked: {}",
106 panic_message(payload.as_ref())
107 )))
108 })
109 }
110
111 fn execute_inner(&self, operation: Operation) -> Result<OperationResult, TuiTestError> {
112 let _operation = self
113 .operations
114 .lock()
115 .unwrap_or_else(std::sync::PoisonError::into_inner);
116 if self.logger.enabled() {
117 self.logger
118 .event(&format!("operation {}", operation_summary(&operation)));
119 }
120 match operation {
121 Operation::Open(options) => self.open(options).map(OperationResult::Open),
122 Operation::Run(options) => self.run(options).map(OperationResult::Open),
123 Operation::Close => {
124 *self
125 .live
126 .lock()
127 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
128 *self
129 .interrupt
130 .lock()
131 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
132 if let Some(session) = self.lock_session().take() {
133 session.kill();
134 }
135 Ok(OperationResult::Unit)
136 }
137 other => self.with_session(|session| dispatch(session, other)),
138 }
139 }
140
141 fn open(&self, options: OpenOptions) -> Result<OpenResult, TuiTestError> {
142 self.spawn(
143 options.shell,
144 None,
145 options.backend,
146 options.profile,
147 options.cols,
148 options.rows,
149 options.cwd,
150 options.env,
151 options.wait_ready,
152 options.restart,
153 options.timeouts,
154 )
155 }
156
157 fn run(&self, options: RunOptions) -> Result<OpenResult, TuiTestError> {
158 let mut program = Vec::with_capacity(options.args.len() + 1);
159 program.push(options.program);
160 program.extend(options.args);
161 self.spawn(
162 None,
163 Some(program),
164 options.backend,
165 options.profile,
166 options.cols,
167 options.rows,
168 options.cwd,
169 options.env,
170 options.wait_ready,
171 options.restart,
172 options.timeouts,
173 )
174 }
175
176 #[allow(clippy::too_many_arguments)]
177 fn spawn(
178 &self,
179 shell: Option<crate::shell::Shell>,
180 program: Option<Vec<String>>,
181 backend: crate::terminal::backend::Backend,
182 profile: crate::profile::Profile,
183 cols: u16,
184 rows: u16,
185 cwd: Option<String>,
186 env: Vec<(String, String)>,
187 wait_ready: Option<bool>,
188 restart: bool,
189 timeouts: crate::api::Timeouts,
190 ) -> Result<OpenResult, TuiTestError> {
191 let mut current = self.lock_session();
192 if let Some(previous) = current.as_ref() {
193 if !restart && previous.is_alive()? {
194 return Ok(OpenResult {
195 shell_pid: previous.pid(),
196 session: self.name.clone(),
197 ready: previous.is_ready(),
198 recording: self.recording_path.to_string_lossy().into_owned(),
199 });
200 }
201 }
202
203 *self
204 .live
205 .lock()
206 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
207 *self
208 .interrupt
209 .lock()
210 .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
211 if let Some(previous) = current.take() {
212 previous.kill();
213 }
214 drop(current);
215 let session = TerminalSession::open(
216 shell,
217 program.clone(),
218 backend,
219 profile,
220 cols,
221 rows,
222 cwd,
223 env,
224 timeouts,
225 self.logger.clone(),
226 self.recording_path.clone(),
227 )
228 .map_err(|error| TuiTestError::internal(format!("failed to open session: {error}")))?;
229
230 let shell_pid = session.pid();
231 let ready_timeout = open_ready_timeout(&session);
232 let ready = if wait_ready.unwrap_or(program.is_none()) {
233 await_ready(&session, ready_timeout)
234 } else {
235 session
236 .state
237 .lock()
238 .unwrap_or_else(std::sync::PoisonError::into_inner)
239 .tracker
240 .is_ready()
241 };
242 if wait_ready == Some(true) && !ready {
243 let message = assertion_message(
244 &session,
245 &format!(
246 "open: the session started but reported no prompt within \
247 {ready_timeout}ms; pass --no-wait-ready if it has no shell \
248 integration"
249 ),
250 );
251 session.kill();
252 return Err(TuiTestError::assertion(message));
253 }
254 let live = LiveTarget {
255 state: session.state.clone(),
256 shell: session.shell.map(|value| value.as_str()),
257 };
258 *self
259 .interrupt
260 .lock()
261 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(InterruptTarget {
262 pty: session.pty.clone(),
263 cancelled: session.cancelled.clone(),
264 });
265 *self.lock_session() = Some(session);
266 *self
267 .live
268 .lock()
269 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(live);
270 Ok(OpenResult {
271 shell_pid,
272 session: self.name.clone(),
273 ready,
274 recording: self.recording_path.to_string_lossy().into_owned(),
275 })
276 }
277
278 fn with_session<F>(&self, operation: F) -> Result<OperationResult, TuiTestError>
279 where
280 F: FnOnce(&mut TerminalSession) -> Result<OperationResult, TuiTestError>,
281 {
282 let mut guard = self.lock_session();
283 let session = guard.as_mut().ok_or_else(TuiTestError::no_session)?;
284 match operation(session) {
285 Err(mut error) if error.kind == ErrorKind::Assertion => {
286 error.message = assertion_message(session, &error.message);
287 Err(error)
288 }
289 result => result,
290 }
291 }
292
293 pub fn status(&self) -> RuntimeStatus {
294 let guard = self.lock_session();
295 match guard.as_ref() {
296 Some(session) => {
297 let state = session
298 .state
299 .lock()
300 .unwrap_or_else(std::sync::PoisonError::into_inner);
301 RuntimeStatus {
302 session: self.name.clone(),
303 shell_pid: session.pid(),
304 cols: Some(session.cols),
305 rows: Some(session.rows),
306 shell: session.shell.map(|value| value.as_str().to_string()),
307 exited: state.exited,
308 timeouts: Some(effective_timeouts(session)),
309 }
310 }
311 None => RuntimeStatus {
312 session: self.name.clone(),
313 shell_pid: None,
314 cols: None,
315 rows: None,
316 shell: None,
317 exited: None,
318 timeouts: None,
319 },
320 }
321 }
322
323 pub fn frame(&self) -> Option<LiveFrame> {
324 let live = self
325 .live
326 .lock()
327 .unwrap_or_else(std::sync::PoisonError::into_inner);
328 live.as_ref().map(|target| {
329 let state = target
330 .state
331 .lock()
332 .unwrap_or_else(std::sync::PoisonError::into_inner);
333 LiveFrame {
334 grid: state.emu.viewable_rows(),
335 cursor: state.emu.cursor(),
336 size: state.emu.size(),
337 exited: state.exited,
338 shell: target.shell,
339 }
340 })
341 }
342
343 pub fn log_event(&self, message: &str) {
344 self.logger.event(message);
345 }
346
347 pub fn interrupt(&self) {
348 let target = self
349 .interrupt
350 .lock()
351 .unwrap_or_else(std::sync::PoisonError::into_inner)
352 .clone();
353 if let Some(target) = target {
354 target
355 .cancelled
356 .store(true, std::sync::atomic::Ordering::Release);
357 target
358 .pty
359 .lock()
360 .unwrap_or_else(std::sync::PoisonError::into_inner)
361 .kill();
362 }
363 }
364
365 pub fn is_open(&self) -> bool {
366 self.lock_session().is_some()
367 }
368
369 pub fn recording_path(&self) -> &PathBuf {
370 &self.recording_path
371 }
372
373 pub fn flush_recording(&self) -> Result<(), TuiTestError> {
374 let _operation = self
375 .operations
376 .lock()
377 .unwrap_or_else(std::sync::PoisonError::into_inner);
378 let guard = self.lock_session();
379 let session = guard.as_ref().ok_or_else(TuiTestError::no_session)?;
380 session.flush_recording()
381 }
382
383 fn lock_session(&self) -> MutexGuard<'_, Option<TerminalSession>> {
384 self.session
385 .lock()
386 .unwrap_or_else(std::sync::PoisonError::into_inner)
387 }
388}
389
390impl Drop for Engine {
391 fn drop(&mut self) {
392 if let Ok(session) = self.session.get_mut() {
393 if let Some(session) = session.take() {
394 session.kill();
395 }
396 }
397 }
398}
399
400fn open_ready_timeout(session: &TerminalSession) -> u64 {
401 session
402 .timeouts
403 .get(config::TimeoutClass::Ready)
404 .or_else(|| config::TimeoutClass::Ready.env_ms())
405 .unwrap_or(config::OPEN_READY_CAP_MS)
406}
407
408fn await_ready(session: &TerminalSession, timeout_ms: u64) -> bool {
409 let start = Instant::now();
410 let cap = Duration::from_millis(timeout_ms);
411 loop {
412 if session.cancelled.load(std::sync::atomic::Ordering::Acquire) {
413 return false;
414 }
415 {
416 let state = session
417 .state
418 .lock()
419 .unwrap_or_else(std::sync::PoisonError::into_inner);
420 if state.tracker.is_ready() {
421 return true;
422 }
423 if state.exited.is_some() {
424 return false;
425 }
426 }
427 if start.elapsed() >= cap {
428 return false;
429 }
430 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
431 }
432}
433
434fn viewable(session: &TerminalSession) -> Vec<Vec<EmuCell>> {
435 session
436 .state
437 .lock()
438 .unwrap_or_else(std::sync::PoisonError::into_inner)
439 .emu
440 .viewable_rows()
441}
442
443fn grid_with_title(
451 session: &TerminalSession,
452 full: bool,
453 include_title: bool,
454) -> (Vec<Vec<EmuCell>>, Option<String>) {
455 let state = session
456 .state
457 .lock()
458 .unwrap_or_else(std::sync::PoisonError::into_inner);
459 let title = if include_title {
460 state.emu.title()
461 } else {
462 None
463 };
464 let rows = if full {
465 state.emu.full_rows()
466 } else {
467 state.emu.viewable_rows()
468 };
469 (rows, title)
470}
471
472fn grid(session: &TerminalSession, full: bool) -> Vec<Vec<EmuCell>> {
473 let state = session
474 .state
475 .lock()
476 .unwrap_or_else(std::sync::PoisonError::into_inner);
477 if full {
478 state.emu.full_rows()
479 } else {
480 state.emu.viewable_rows()
481 }
482}
483
484fn text_of(rows: &[Vec<EmuCell>]) -> String {
485 rows_to_strings(rows)
486 .iter()
487 .map(|line| line.trim_end())
488 .collect::<Vec<_>>()
489 .join("\n")
490 .trim_end()
491 .to_string()
492}
493
494fn dispatch(
495 session: &mut TerminalSession,
496 operation: Operation,
497) -> Result<OperationResult, TuiTestError> {
498 match operation {
499 Operation::State => Ok(OperationResult::State(state(session))),
500 Operation::Text { full } => Ok(OperationResult::Text(text_of(&grid(session, full)))),
501 Operation::PackedScreen { full } => {
502 Ok(OperationResult::PackedScreen(packed_screen(session, full)))
503 }
504 Operation::Cells { x, y, w, h } => Ok(OperationResult::Cells(cells(session, x, y, w, h))),
505 Operation::GetCommand => Ok(OperationResult::Command(
506 session
507 .state
508 .lock()
509 .unwrap_or_else(std::sync::PoisonError::into_inner)
510 .tracker
511 .last_command()
512 .map(str::to_string),
513 )),
514 Operation::GetOutput => Ok(OperationResult::Output(
515 session
516 .state
517 .lock()
518 .unwrap_or_else(std::sync::PoisonError::into_inner)
519 .tracker
520 .last_output()
521 .map(str::to_string),
522 )),
523 Operation::GetExitCode => Ok(OperationResult::ExitCode(
524 session
525 .state
526 .lock()
527 .unwrap_or_else(std::sync::PoisonError::into_inner)
528 .tracker
529 .last_exit(),
530 )),
531 Operation::GetCwd => Ok(OperationResult::Cwd(
532 session
533 .state
534 .lock()
535 .unwrap_or_else(std::sync::PoisonError::into_inner)
536 .tracker
537 .cwd()
538 .map(str::to_string),
539 )),
540 Operation::GetTitle => Ok(OperationResult::Title(title_of(session))),
541 Operation::GetCursor => {
542 let (x, y) = session
543 .state
544 .lock()
545 .unwrap_or_else(std::sync::PoisonError::into_inner)
546 .emu
547 .cursor();
548 Ok(OperationResult::Cursor(Cursor { x, y }))
549 }
550 Operation::GetSize => {
551 let (cols, rows) = session
552 .state
553 .lock()
554 .unwrap_or_else(std::sync::PoisonError::into_inner)
555 .emu
556 .size();
557 Ok(OperationResult::Size(Size { cols, rows }))
558 }
559 Operation::GetBellCount => Ok(OperationResult::BellCount(session.bells.count())),
560 Operation::GetBellEvents => {
561 Ok(OperationResult::BellEvents(session.bells.snapshot().events))
562 }
563 Operation::Write { data } => {
564 act(session.write(data.as_bytes()))?;
565 Ok(OperationResult::Unit)
566 }
567 Operation::Submit { data } => {
568 act(session.submit(&data.unwrap_or_default()))?;
569 Ok(OperationResult::Unit)
570 }
571 Operation::Key { keys, action } => {
572 key_action(session, keys, action)?;
573 Ok(OperationResult::Unit)
574 }
575 Operation::Mouse { action } => {
576 mouse_action(session, action)?;
577 Ok(OperationResult::Unit)
578 }
579 Operation::Resize { cols, rows } => {
580 act(session.resize(cols, rows))?;
581 Ok(OperationResult::Unit)
582 }
583 Operation::Signal { name } => {
584 act(session
585 .pty
586 .lock()
587 .unwrap_or_else(std::sync::PoisonError::into_inner)
588 .signal(&name))?;
589 Ok(OperationResult::Unit)
590 }
591 Operation::WaitText {
592 text,
593 regex,
594 full,
595 timeout_ms,
596 not,
597 } => {
598 wait_text(
599 session,
600 &text,
601 regex,
602 full,
603 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
604 not,
605 )?;
606 Ok(OperationResult::Unit)
607 }
608 Operation::WaitTitle {
609 text,
610 regex,
611 timeout_ms,
612 not,
613 } => {
614 wait_title(
615 session,
616 &text,
617 regex,
618 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
619 not,
620 )?;
621 Ok(OperationResult::Unit)
622 }
623 Operation::WaitIdle { timeout_ms } => {
624 wait_idle(
625 session,
626 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Idle)),
627 )?;
628 Ok(OperationResult::Unit)
629 }
630 Operation::WaitCommand { timeout_ms } => {
631 wait_command(
632 session,
633 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Command)),
634 )?;
635 Ok(OperationResult::Unit)
636 }
637 Operation::WaitExit { timeout_ms } => {
638 wait_exit(
639 session,
640 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Exit)),
641 )?;
642 Ok(OperationResult::Unit)
643 }
644 Operation::WaitReady { timeout_ms } => {
645 wait_ready(
646 session,
647 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Ready)),
648 )?;
649 Ok(OperationResult::Unit)
650 }
651 Operation::WaitBell { timeout_ms } => {
652 wait_bell(
653 session,
654 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
655 )?;
656 Ok(OperationResult::Unit)
657 }
658 Operation::ExpectText {
659 text,
660 regex,
661 full,
662 strict,
663 not,
664 fg,
665 bg,
666 timeout_ms,
667 } => {
668 expect_text(
669 session,
670 &text,
671 regex,
672 full,
673 strict,
674 not,
675 fg,
676 bg,
677 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
678 )?;
679 Ok(OperationResult::Unit)
680 }
681 Operation::ExpectTitle {
682 text,
683 regex,
684 not,
685 timeout_ms,
686 } => {
687 expect_title(
688 session,
689 &text,
690 regex,
691 not,
692 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
693 )?;
694 Ok(OperationResult::Unit)
695 }
696 Operation::ExpectExitCode { code, timeout_ms } => {
697 expect_exit_code(
698 session,
699 code,
700 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Command)),
701 )?;
702 Ok(OperationResult::Unit)
703 }
704 Operation::ExpectOutput { text, regex } => {
705 expect_output(session, &text, regex)?;
706 Ok(OperationResult::Unit)
707 }
708 Operation::ExpectBellCount { count, timeout_ms } => {
709 expect_bell_count(
710 session,
711 count,
712 timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)),
713 )?;
714 Ok(OperationResult::Unit)
715 }
716 Operation::Snapshot {
717 name,
718 update,
719 include_colors,
720 include_title,
721 cwd,
722 } => Ok(OperationResult::Snapshot(do_snapshot(
723 session,
724 &name,
725 update,
726 include_colors,
727 include_title,
728 cwd,
729 )?)),
730 Operation::Screenshot { full, path, zoom } => Ok(OperationResult::Screenshot(screenshot(
731 session, full, path, zoom,
732 )?)),
733 Operation::StartRecording {
734 path,
735 format,
736 fps,
737 speed,
738 idle_time_limit,
739 zoom,
740 } => {
741 session.start_recording(path, format, fps, speed, idle_time_limit, zoom)?;
742 Ok(OperationResult::Unit)
743 }
744 Operation::StopRecording => Ok(OperationResult::Recording(session.stop_recording()?)),
745 Operation::Open(_) | Operation::Run(_) | Operation::Close => {
746 Err(TuiTestError::internal("unsupported nested operation"))
747 }
748 }
749}
750
751fn act(result: anyhow::Result<()>) -> Result<(), TuiTestError> {
752 result.map_err(|error| TuiTestError::internal(error.to_string()))
753}
754
755fn state(session: &TerminalSession) -> crate::api::State {
756 let state = session
757 .state
758 .lock()
759 .unwrap_or_else(std::sync::PoisonError::into_inner);
760 let (x, y) = state.emu.cursor();
761 let (cols, rows) = state.emu.size();
762 let bells = session.bells.snapshot();
763 crate::api::State {
764 session_shell: session.shell.map(|value| value.as_str().to_string()),
765 cols,
766 rows,
767 cursor: Cursor { x, y },
768 title: state.emu.title(),
769 cwd: state.tracker.cwd().map(str::to_string),
770 last_command: state.tracker.last_command().map(str::to_string),
771 last_exit: state.tracker.last_exit(),
772 exited: state.exited,
773 ready: state.tracker.is_ready(),
774 bell_count: bells.count,
775 timeouts: effective_timeouts(session),
776 text: text_of(&state.emu.viewable_rows()),
777 }
778}
779
780fn effective_timeouts(session: &TerminalSession) -> EffectiveTimeouts {
781 use config::TimeoutClass::*;
782 EffectiveTimeouts {
783 text: session.timeout_for(Text),
784 idle: session.timeout_for(Idle),
785 command: session.timeout_for(Command),
786 exit: session.timeout_for(Exit),
787 ready: session.timeout_for(Ready),
788 }
789}
790
791fn packed_screen(session: &TerminalSession, full: bool) -> PackedScreen {
792 let rows = grid(session, full);
793 PackedScreen {
794 cols: session.cols,
795 rows: rows.len().min(u16::MAX as usize) as u16,
796 utf8: rows_to_strings(&rows).join("\n").into_bytes(),
797 }
798}
799
800fn cells(session: &TerminalSession, x: u16, y: u16, w: u16, h: u16) -> Vec<Cell> {
801 let rows = viewable(session);
802 let mut out = Vec::new();
803 for row in y..y.saturating_add(h.max(1)) {
804 for col in x..x.saturating_add(w.max(1)) {
805 if let Some(cell) = rows
806 .get(row as usize)
807 .and_then(|line| line.get(col as usize))
808 {
809 out.push(cell_model(col, row, cell));
810 }
811 }
812 }
813 out
814}
815
816fn cell_model(x: u16, y: u16, cell: &EmuCell) -> Cell {
817 Cell {
818 x,
819 y,
820 char: cell.ch.to_string(),
821 fg: cell_color(cell.fg),
822 bg: cell_color(cell.bg),
823 bold: cell.has(Attrs::BOLD),
824 dim: cell.has(Attrs::DIM),
825 italic: cell.has(Attrs::ITALIC),
826 inverse: cell.has(Attrs::INVERSE),
827 invisible: cell.has(Attrs::INVISIBLE),
828 strike: cell.has(Attrs::STRIKE),
829 blink: cell.has(Attrs::BLINK),
830 underline: cell.underline.is_underlined(),
831 underline_style: cell.underline.name().to_string(),
832 underline_color: cell_color(cell.underline_color),
833 }
834}
835
836fn cell_color(color: Option<Color>) -> CellColor {
837 match color {
838 None => CellColor::Default,
839 Some(Color::Rgb(r, g, b)) => CellColor::Rgb(r, g, b),
840 Some(color) => CellColor::Indexed(color.to_index()),
841 }
842}
843
844fn key_action(
845 session: &TerminalSession,
846 tokens: Vec<String>,
847 action: crate::api::KeyAction,
848) -> Result<(), TuiTestError> {
849 let keyboard_mode = session
850 .state
851 .lock()
852 .unwrap_or_else(std::sync::PoisonError::into_inner)
853 .emu
854 .keyboard_mode();
855 let sequence = keys::tokens_to_seq_for_action_with_mode(&tokens, action, keyboard_mode)
856 .map_err(|error| TuiTestError::usage(error.to_string()))?;
857 if sequence.is_empty() {
858 Ok(())
859 } else {
860 act(session.write(sequence.as_bytes()))
861 }
862}
863
864fn mouse_action(
865 session: &TerminalSession,
866 action: crate::api::MouseAction,
867) -> Result<(), TuiTestError> {
868 let sequence = match action {
869 crate::api::MouseAction::Click {
870 x,
871 y,
872 on_text,
873 button,
874 clicks,
875 } => {
876 let (x, y) = if let Some(text) = on_text {
877 locate_center(session, &text).ok_or_else(|| {
878 TuiTestError::assertion(format!("text not found on screen: {text}"))
879 })?
880 } else {
881 (x.unwrap_or(0), y.unwrap_or(0))
882 };
883 let mut out = String::new();
884 for _ in 0..clicks.max(1) {
885 out.push_str(&mouse::click(x, y, button));
886 }
887 out
888 }
889 crate::api::MouseAction::Move { x, y } => mouse::motion(x, y),
890 crate::api::MouseAction::Down { x, y, button } => mouse::down(x, y, button),
891 crate::api::MouseAction::Up { x, y, button } => mouse::up(x, y, button),
892 crate::api::MouseAction::Drag {
893 x1,
894 y1,
895 x2,
896 y2,
897 button,
898 } => format!(
899 "{}{}{}",
900 mouse::down(x1, y1, button),
901 mouse::motion(x2, y2),
902 mouse::up(x2, y2, button)
903 ),
904 crate::api::MouseAction::Scroll { direction, amount } => {
905 let up = direction.eq_ignore_ascii_case("up");
906 (0..amount.max(1))
907 .map(|_| mouse::scroll(0, 0, up))
908 .collect()
909 }
910 };
911 act(session.write(sequence.as_bytes()))
912}
913
914fn locate_center(session: &TerminalSession, text: &str) -> Option<(u16, u16)> {
915 let rows = viewable(session);
916 let pattern = Pattern::new(text, false).ok()?;
917 let cells = locator::find(&rows, &pattern, false).ok()??;
918 if cells.is_empty() {
919 return None;
920 }
921 let middle = &cells[cells.len() / 2];
922 Some((middle.x as u16, middle.y as u16))
923}
924
925fn poll_until<F: FnMut() -> bool>(mut predicate: F, timeout_ms: u64) -> bool {
926 let start = Instant::now();
927 loop {
928 if predicate() {
929 return true;
930 }
931 if start.elapsed() >= Duration::from_millis(timeout_ms) {
932 return false;
933 }
934 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
935 }
936}
937
938fn matches_now(
939 session: &TerminalSession,
940 pattern: &Pattern,
941 full: bool,
942 strict: bool,
943) -> anyhow::Result<bool> {
944 Ok(locator::find(&grid(session, full), pattern, strict)?.is_some())
945}
946
947fn session_stopped(session: &TerminalSession) -> bool {
948 session.cancelled.load(std::sync::atomic::Ordering::Acquire)
949 || session
950 .state
951 .lock()
952 .unwrap_or_else(std::sync::PoisonError::into_inner)
953 .exited
954 .is_some()
955}
956
957fn wait_text(
958 session: &TerminalSession,
959 text: &str,
960 regex: bool,
961 full: bool,
962 timeout_ms: u64,
963 not: bool,
964) -> Result<(), TuiTestError> {
965 let pattern = Pattern::new(text, regex)
966 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?;
967 let mut matched = false;
968 poll_until(
969 || {
970 matched = matches_now(session, &pattern, full, false).unwrap_or(false) != not;
971 matched || session_stopped(session)
972 },
973 timeout_ms,
974 );
975 if matched {
976 Ok(())
977 } else if session_stopped(session) {
978 Err(TuiTestError::assertion(format!(
979 "session exited before '{}' became {}",
980 pattern.describe(),
981 if not { "hidden" } else { "visible" }
982 )))
983 } else {
984 Err(TuiTestError::assertion(timeout_message(
985 &pattern.describe(),
986 timeout_ms,
987 not,
988 )))
989 }
990}
991
992fn title_of(session: &TerminalSession) -> Option<String> {
994 session
995 .state
996 .lock()
997 .unwrap_or_else(std::sync::PoisonError::into_inner)
998 .emu
999 .title()
1000}
1001
1002fn title_matches(session: &TerminalSession, pattern: &Pattern) -> bool {
1005 title_of(session).is_some_and(|title| pattern.matches(&title))
1006}
1007
1008fn wait_title(
1009 session: &TerminalSession,
1010 text: &str,
1011 regex: bool,
1012 timeout_ms: u64,
1013 not: bool,
1014) -> Result<(), TuiTestError> {
1015 let pattern = Pattern::new(text, regex)
1016 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?;
1017 let mut matched = false;
1018 poll_until(
1019 || {
1020 matched = title_matches(session, &pattern) != not;
1021 matched || session_stopped(session)
1022 },
1023 timeout_ms,
1024 );
1025 if matched {
1026 Ok(())
1027 } else if session_stopped(session) {
1028 Err(TuiTestError::assertion(format!(
1029 "session exited before the title '{}' became {}",
1030 pattern.describe(),
1031 if not { "hidden" } else { "visible" }
1032 )))
1033 } else {
1034 Err(TuiTestError::assertion(title_timeout_message(
1035 session,
1036 &pattern.describe(),
1037 timeout_ms,
1038 not,
1039 )))
1040 }
1041}
1042
1043fn expect_title(
1044 session: &TerminalSession,
1045 text: &str,
1046 regex: bool,
1047 not: bool,
1048 timeout_ms: u64,
1049) -> Result<(), TuiTestError> {
1050 let pattern = Pattern::new(text, regex)
1051 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?;
1052 let mut matched = false;
1053 poll_until(
1054 || {
1055 matched = title_matches(session, &pattern) != not;
1056 matched || session_stopped(session)
1057 },
1058 timeout_ms,
1059 );
1060 if matched {
1061 Ok(())
1062 } else if session_stopped(session) {
1063 Err(TuiTestError::assertion(format!(
1064 "session exited before the title '{}' became {}",
1065 pattern.describe(),
1066 if not { "hidden" } else { "visible" }
1067 )))
1068 } else {
1069 Err(TuiTestError::assertion(title_timeout_message(
1070 session,
1071 &pattern.describe(),
1072 timeout_ms,
1073 not,
1074 )))
1075 }
1076}
1077
1078fn title_timeout_message(
1082 session: &TerminalSession,
1083 pattern: &str,
1084 timeout_ms: u64,
1085 not: bool,
1086) -> String {
1087 let actual = match title_of(session) {
1088 Some(title) => format!("'{title}'"),
1089 None => "no title set".to_string(),
1090 };
1091 format!(
1092 "timed out after {} waiting for the title '{pattern}' to be {}; the title is {actual}",
1093 format_timeout(timeout_ms),
1094 if not { "hidden" } else { "visible" },
1095 )
1096}
1097
1098fn wait_idle(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
1099 let quiet = Duration::from_millis(250);
1100 if poll_until(
1101 || {
1102 session
1103 .state
1104 .lock()
1105 .unwrap_or_else(std::sync::PoisonError::into_inner)
1106 .last_change
1107 .elapsed()
1108 >= quiet
1109 || session.cancelled.load(std::sync::atomic::Ordering::Acquire)
1110 },
1111 timeout_ms,
1112 ) {
1113 Ok(())
1114 } else {
1115 Err(TuiTestError::assertion(
1116 "wait idle: screen kept changing until timeout",
1117 ))
1118 }
1119}
1120
1121fn awaiting_command_start(state: &TermState) -> bool {
1122 state
1123 .awaiting_start
1124 .is_some_and(|seen| state.tracker.started_count() == seen)
1125}
1126
1127fn command_settled(session: &TerminalSession, baseline: u64) -> bool {
1128 const QUIET: Duration = Duration::from_millis(300);
1129 if session.cancelled.load(std::sync::atomic::Ordering::Acquire) {
1130 return true;
1131 }
1132 let state = session
1133 .state
1134 .lock()
1135 .unwrap_or_else(std::sync::PoisonError::into_inner);
1136 if state.exited.is_some() {
1137 return true;
1138 }
1139 let tracker = &state.tracker;
1140 if !tracker.started() {
1141 return state.last_change.elapsed() >= QUIET;
1142 }
1143 if awaiting_command_start(&state) {
1144 return false;
1145 }
1146 tracker.finished_count() > baseline || !tracker.executing()
1147}
1148
1149fn wait_command(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
1150 let baseline = session
1151 .state
1152 .lock()
1153 .unwrap_or_else(std::sync::PoisonError::into_inner)
1154 .tracker
1155 .finished_count();
1156 if poll_until(|| command_settled(session, baseline), timeout_ms) {
1157 Ok(())
1158 } else {
1159 Err(TuiTestError::assertion(format!(
1160 "wait command: timed out after {timeout_ms}ms; {}",
1161 stall_reason(session)
1162 )))
1163 }
1164}
1165
1166fn stall_reason(session: &TerminalSession) -> String {
1167 let state = session
1168 .state
1169 .lock()
1170 .unwrap_or_else(std::sync::PoisonError::into_inner);
1171 if awaiting_command_start(&state) {
1172 "the shell never started a command for the input that was sent, so there \
1173 is nothing to wait for (was the line submitted?)"
1174 .to_string()
1175 } else {
1176 "the command was still running".to_string()
1177 }
1178}
1179
1180fn wait_exit(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
1181 let start = Instant::now();
1182 loop {
1183 let (exited, exit_error) = {
1184 let state = session
1185 .state
1186 .lock()
1187 .unwrap_or_else(std::sync::PoisonError::into_inner);
1188 (state.exited.is_some(), state.exit_error.clone())
1189 };
1190 if exited || session.cancelled.load(std::sync::atomic::Ordering::Acquire) {
1191 return Ok(());
1192 }
1193 if let Some(error) = exit_error {
1194 return Err(TuiTestError::internal(format!(
1195 "wait exit: failed to query process status: {error}"
1196 )));
1197 }
1198 if start.elapsed() >= Duration::from_millis(timeout_ms) {
1199 return Err(TuiTestError::assertion(
1200 "wait exit: session still running at timeout",
1201 ));
1202 }
1203 std::thread::sleep(Duration::from_millis(POLL_DELAY_MS));
1204 }
1205}
1206
1207fn wait_ready(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
1208 if await_ready(session, timeout_ms) {
1209 Ok(())
1210 } else {
1211 Err(TuiTestError::assertion(
1212 "wait ready: no prompt was reported within timeout",
1213 ))
1214 }
1215}
1216
1217fn wait_bell(session: &TerminalSession, timeout_ms: u64) -> Result<(), TuiTestError> {
1218 let baseline = session.bells.sequence();
1219 let mut rang = false;
1220 poll_until(
1221 || {
1222 rang = session.bells.sequence() != baseline;
1223 rang || session_stopped(session)
1224 },
1225 timeout_ms,
1226 );
1227 if rang {
1228 Ok(())
1229 } else if session_stopped(session) {
1230 Err(TuiTestError::assertion(
1231 "session exited before a bell was received",
1232 ))
1233 } else {
1234 Err(TuiTestError::assertion(format!(
1235 "wait bell: timed out after {timeout_ms}ms without receiving a bell"
1236 )))
1237 }
1238}
1239
1240#[allow(clippy::too_many_arguments)]
1241fn expect_text(
1242 session: &TerminalSession,
1243 text: &str,
1244 regex: bool,
1245 full: bool,
1246 strict: bool,
1247 not: bool,
1248 fg: Option<String>,
1249 bg: Option<String>,
1250 timeout_ms: u64,
1251) -> Result<(), TuiTestError> {
1252 let pattern = Pattern::new(text, regex)
1253 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?;
1254
1255 for spec in [&fg, &bg].into_iter().flatten() {
1256 Expected::parse(spec).map_err(|error| TuiTestError::usage(error.to_string()))?;
1257 }
1258
1259 if fg.is_none() && bg.is_none() && not {
1260 let mut gone = false;
1261 poll_until(
1262 || {
1263 gone = !matches_now(session, &pattern, full, false).unwrap_or(true);
1264 gone || session_stopped(session)
1265 },
1266 timeout_ms,
1267 );
1268 return if gone {
1269 Ok(())
1270 } else if session_stopped(session) {
1271 Err(TuiTestError::assertion(format!(
1272 "session exited before '{}' became hidden",
1273 pattern.describe()
1274 )))
1275 } else {
1276 Err(TuiTestError::assertion(timeout_message(
1277 &pattern.describe(),
1278 timeout_ms,
1279 true,
1280 )))
1281 };
1282 }
1283
1284 let mut last_error = None;
1285 let mut matched = false;
1286 poll_until(
1287 || {
1288 matched = match locator::find(&grid(session, full), &pattern, strict) {
1289 Ok(Some(cells)) if !cells.is_empty() => {
1290 if let Some(error) = check_colors(
1291 &cells,
1292 &fg,
1293 &bg,
1294 not,
1295 session
1296 .state
1297 .lock()
1298 .unwrap_or_else(std::sync::PoisonError::into_inner)
1299 .emu
1300 .as_ref(),
1301 ) {
1302 last_error = Some(error);
1303 false
1304 } else {
1305 true
1306 }
1307 }
1308 Ok(_) => false,
1309 Err(error) => {
1310 last_error = Some(error.to_string());
1311 false
1312 }
1313 };
1314 matched || session_stopped(session)
1315 },
1316 timeout_ms,
1317 );
1318
1319 if matched {
1320 Ok(())
1321 } else if let Some(error) = last_error {
1322 Err(TuiTestError::assertion(error))
1323 } else if session_stopped(session) {
1324 Err(TuiTestError::assertion(format!(
1325 "session exited before '{}' matched",
1326 pattern.describe()
1327 )))
1328 } else {
1329 Err(TuiTestError::assertion(timeout_message(
1330 &pattern.describe(),
1331 timeout_ms,
1332 false,
1333 )))
1334 }
1335}
1336
1337fn check_colors(
1338 cells: &[locator::MatchedCell],
1339 fg: &Option<String>,
1340 bg: &Option<String>,
1341 not: bool,
1342 colors: &dyn crate::terminal::emu::Emulator,
1343) -> Option<String> {
1344 let want = !not;
1345 if let Some(spec) = fg {
1346 let expected = Expected::parse(spec).ok()?;
1347 for cell in cells {
1348 if color::matches(cell.cell.fg, &expected, colors, true) != want {
1349 return Some(format!(
1350 "expected fg {} {}, found {} in cell '{}' at {},{}",
1351 if not { "absent" } else { "present" },
1352 expected.describe(),
1353 color::describe_cell(cell.cell.fg, &expected, colors, true),
1354 cell.cell.ch,
1355 cell.x,
1356 cell.y
1357 ));
1358 }
1359 }
1360 }
1361 if let Some(spec) = bg {
1362 let expected = Expected::parse(spec).ok()?;
1363 for cell in cells {
1364 if color::matches(cell.cell.bg, &expected, colors, false) != want {
1365 return Some(format!(
1366 "expected bg {} {}, found {} in cell '{}' at {},{}",
1367 if not { "absent" } else { "present" },
1368 expected.describe(),
1369 color::describe_cell(cell.cell.bg, &expected, colors, false),
1370 cell.cell.ch,
1371 cell.x,
1372 cell.y
1373 ));
1374 }
1375 }
1376 }
1377 None
1378}
1379
1380fn expect_exit_code(
1381 session: &TerminalSession,
1382 code: i32,
1383 timeout_ms: u64,
1384) -> Result<(), TuiTestError> {
1385 let baseline = session
1386 .state
1387 .lock()
1388 .unwrap_or_else(std::sync::PoisonError::into_inner)
1389 .tracker
1390 .finished_count();
1391 if !poll_until(|| command_settled(session, baseline), timeout_ms) {
1392 return Err(TuiTestError::assertion(format!(
1393 "expected exit code {code}: timed out after {timeout_ms}ms; {}",
1394 stall_reason(session)
1395 )));
1396 }
1397 match session
1398 .state
1399 .lock()
1400 .unwrap_or_else(std::sync::PoisonError::into_inner)
1401 .tracker
1402 .last_exit()
1403 {
1404 Some(actual) if actual == code => Ok(()),
1405 Some(actual) => Err(TuiTestError::assertion(format!(
1406 "expected exit code {code}, got {actual}"
1407 ))),
1408 None => Err(TuiTestError::assertion("no command exit code tracked yet")),
1409 }
1410}
1411
1412fn expect_output(session: &TerminalSession, text: &str, regex: bool) -> Result<(), TuiTestError> {
1413 let output = session
1414 .state
1415 .lock()
1416 .unwrap_or_else(std::sync::PoisonError::into_inner)
1417 .tracker
1418 .last_output()
1419 .map(str::to_string)
1420 .ok_or_else(|| TuiTestError::assertion("no command output tracked yet"))?;
1421 let matched = if regex {
1422 regex::Regex::new(text)
1423 .map_err(|error| TuiTestError::usage(format!("invalid regex: {error}")))?
1424 .is_match(&output)
1425 } else {
1426 output.contains(text)
1427 };
1428 if matched {
1429 Ok(())
1430 } else {
1431 Err(TuiTestError::assertion(format!(
1432 "output did not contain '{text}'\n---\n{output}\n---"
1433 )))
1434 }
1435}
1436
1437fn expect_bell_count(
1438 session: &TerminalSession,
1439 expected: u64,
1440 timeout_ms: u64,
1441) -> Result<(), TuiTestError> {
1442 let mut actual = session.bells.count();
1443 poll_until(
1444 || {
1445 actual = session.bells.count();
1446 actual >= expected || session_stopped(session)
1447 },
1448 timeout_ms,
1449 );
1450 if actual >= expected {
1451 Ok(())
1452 } else if session_stopped(session) {
1453 Err(TuiTestError::assertion(format!(
1454 "session exited at bell count {actual} before reaching {expected}"
1455 )))
1456 } else {
1457 Err(TuiTestError::assertion(format!(
1458 "expected bell count {expected}: timed out after {timeout_ms}ms; current count is {actual}"
1459 )))
1460 }
1461}
1462
1463fn do_snapshot(
1464 session: &TerminalSession,
1465 name: &str,
1466 update: bool,
1467 include_colors: bool,
1468 include_title: bool,
1469 cwd: Option<String>,
1470) -> Result<SnapshotResult, TuiTestError> {
1471 let (rows, title) = grid_with_title(session, false, include_title);
1475 let content = snapshot::serialize(&rows, session.cols, include_colors, title.as_deref());
1476 let base = cwd
1477 .map(std::path::PathBuf::from)
1478 .or_else(|| std::env::current_dir().ok())
1479 .unwrap_or_default();
1480 match snapshot::compare(&base, name, &content, update) {
1481 Ok(SnapshotStatus::Passed) => Ok(SnapshotResult::Passed),
1482 Ok(SnapshotStatus::Written) => Ok(SnapshotResult::Written),
1483 Ok(SnapshotStatus::Updated) => Ok(SnapshotResult::Updated),
1484 Ok(SnapshotStatus::Failed { expected, actual }) => Err(TuiTestError::assertion(format!(
1485 "snapshot mismatch\n--- expected ---\n{expected}\n--- actual ---\n{actual}"
1486 ))),
1487 Err(error) => Err(TuiTestError::internal(error.to_string())),
1488 }
1489}
1490
1491fn cursor_in(
1497 rows: &[Vec<EmuCell>],
1498 emu: &dyn crate::terminal::emu::Emulator,
1499) -> Option<(u16, usize)> {
1500 if !emu.cursor_visible() {
1501 return None;
1502 }
1503 let (x, y) = emu.cursor();
1504 let (_, screen) = emu.size();
1505 let history = rows.len().saturating_sub(screen as usize);
1509 Some((x, history + y as usize))
1510}
1511
1512struct SvgSnapshot {
1513 rows: Vec<Vec<EmuCell>>,
1514 cols: u16,
1515 title: Option<String>,
1516 cursor: Option<(u16, usize)>,
1517 render_state: crate::render::svg::RenderState,
1518}
1519
1520fn svg_snapshot_from(emu: &dyn Emulator, full: bool) -> SvgSnapshot {
1521 let rows = if full {
1522 emu.full_rows()
1523 } else {
1524 emu.viewable_rows()
1525 };
1526 SvgSnapshot {
1527 cols: emu.size().0,
1528 title: emu.title(),
1529 cursor: cursor_in(&rows, emu),
1530 render_state: crate::render::svg::RenderState::capture(emu),
1531 rows,
1532 }
1533}
1534
1535fn svg_snapshot(session: &TerminalSession, full: bool) -> SvgSnapshot {
1538 let state = session
1539 .state
1540 .lock()
1541 .unwrap_or_else(std::sync::PoisonError::into_inner);
1542 svg_snapshot_from(state.emu.as_ref(), full)
1543}
1544
1545fn screenshot(
1546 session: &TerminalSession,
1547 full: bool,
1548 path: Option<String>,
1549 zoom: Option<f64>,
1550) -> Result<ScreenshotResult, TuiTestError> {
1551 match path {
1552 Some(path) => {
1553 let zoom = crate::api::resolve_zoom(zoom)?;
1554 let snapshot = svg_snapshot(session, full);
1555 let svg = crate::render::svg::render_svg_with_zoom(
1556 &snapshot.rows,
1557 snapshot.cols,
1558 &snapshot.render_state,
1559 snapshot.cursor,
1560 snapshot.title.as_deref(),
1561 zoom,
1562 );
1563 std::fs::write(&path, svg)
1564 .map_err(|error| TuiTestError::internal(error.to_string()))?;
1565 Ok(ScreenshotResult::Path(path))
1566 }
1567 None if zoom.is_some() => Err(TuiTestError::usage(
1568 "screenshot zoom requires an output path",
1569 )),
1570 None => Ok(ScreenshotResult::Text(text_of(&grid(session, full)))),
1571 }
1572}
1573
1574fn timeout_message(pattern: &str, timeout_ms: u64, not: bool) -> String {
1575 format!(
1576 "timed out after {} waiting for '{pattern}' to be {}",
1577 format_timeout(timeout_ms),
1578 if not { "hidden" } else { "visible" }
1579 )
1580}
1581
1582fn assertion_message(session: &TerminalSession, message: &str) -> String {
1583 let (rows, title) = grid_with_title(session, false, true);
1584 let screen = snapshot::serialize(&rows, session.cols, false, title.as_deref());
1585 format!("{message}\n\nTerminal content:\n{screen}")
1586}
1587
1588fn format_timeout(timeout_ms: u64) -> String {
1589 if timeout_ms.is_multiple_of(1_000) {
1590 format!("{}s", timeout_ms / 1_000)
1591 } else {
1592 format!("{timeout_ms}ms")
1593 }
1594}
1595
1596fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str {
1597 if let Some(message) = payload.downcast_ref::<&'static str>() {
1598 message
1599 } else if let Some(message) = payload.downcast_ref::<String>() {
1600 message.as_str()
1601 } else {
1602 "unknown panic"
1603 }
1604}
1605
1606#[cfg(test)]
1607mod tests {
1608 use super::*;
1609 use crate::profile::Profile;
1610 use crate::terminal::alacritty::AlacrittyEmu;
1611 use crate::terminal::cell::{NamedColor, UnderlineStyle};
1612 use crate::terminal::emu::Emulator;
1613
1614 #[test]
1615 fn an_svg_snapshot_freezes_grid_palette_and_cursor_together() {
1616 let mut emu = AlacrittyEmu::new(2, 2, &Profile::default());
1617 emu.process(b"X\x1b[1G\x1b]12;#010203\x07");
1618 let snapshot = svg_snapshot_from(&emu, false);
1619
1620 emu.process(b"Y\x1b[2;2H\x1b[?25l\x1b[6 q\x1b]12;#ff00ff\x07");
1624 let svg = crate::render::svg::render_svg(
1625 &snapshot.rows,
1626 snapshot.cols,
1627 &snapshot.render_state,
1628 snapshot.cursor,
1629 snapshot.title.as_deref(),
1630 );
1631
1632 assert_eq!(svg.matches('X').count(), 2, "text plus block redraw: {svg}");
1633 assert!(!svg.contains('Y'), "later grid contents leaked in: {svg}");
1634 assert!(
1635 svg.contains("#010203"),
1636 "captured cursor color is used: {svg}"
1637 );
1638 assert!(
1639 !svg.contains("#ff00ff"),
1640 "later cursor state must not leak in: {svg}"
1641 );
1642 }
1643
1644 #[test]
1645 fn cell_model_reports_the_whole_vocabulary() {
1646 let cell = EmuCell {
1647 ch: "x".into(),
1648 fg: Some(Color::Named(NamedColor::Red)),
1649 bg: Some(Color::Idx(196)),
1650 underline: UnderlineStyle::Curly,
1651 underline_color: Some(Color::Rgb(1, 2, 3)),
1652 attrs: Attrs::all(),
1653 };
1654 let value = cell_model(3, 4, &cell);
1655 assert_eq!(value.x, 3);
1656 assert_eq!(value.char, "x");
1657 assert_eq!(value.fg, CellColor::Indexed(1));
1658 assert_eq!(value.bg, CellColor::Indexed(196));
1659 assert!(value.bold);
1660 assert!(value.dim);
1661 assert!(value.italic);
1662 assert!(value.inverse);
1663 assert!(value.invisible);
1664 assert!(value.strike);
1665 assert!(value.blink);
1666 assert!(value.underline);
1667 assert_eq!(value.underline_style, "curly");
1668 assert_eq!(value.underline_color, CellColor::Rgb(1, 2, 3));
1669 }
1670
1671 #[test]
1672 fn cell_model_underline_fields_are_never_absent() {
1673 let value = cell_model(0, 0, &EmuCell::blank());
1674 assert!(!value.underline);
1675 assert_eq!(value.underline_style, "none");
1676 assert_eq!(value.underline_color, CellColor::Default);
1677 assert!(!value.blink);
1678
1679 let cell = EmuCell {
1680 underline: UnderlineStyle::Single,
1681 underline_color: None,
1682 ..EmuCell::blank()
1683 };
1684 let value = cell_model(0, 0, &cell);
1685 assert!(value.underline);
1686 assert_eq!(value.underline_style, "single");
1687 assert_eq!(value.underline_color, CellColor::Default);
1688 }
1689
1690 #[test]
1691 fn panic_payloads_become_internal_errors() {
1692 let error = std::panic::catch_unwind(|| panic!("ffi-panic"))
1693 .map_err(|payload| {
1694 TuiTestError::internal(format!(
1695 "native terminal operation panicked: {}",
1696 panic_message(payload.as_ref())
1697 ))
1698 })
1699 .unwrap_err();
1700 assert_eq!(error.kind, ErrorKind::Internal);
1701 assert!(error.message.contains("ffi-panic"));
1702 }
1703}