Skip to main content

tui_test/
engine.rs

1//! Reusable in-process terminal engine.
2
3use 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
51/// One-line operation description for the verbose log. Open and Run redact env
52/// values (they may contain secrets) and report only the variable count.
53fn 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
443/// The visible screen and the window title as of a single instant.
444///
445/// Read under one lock. Taking them separately lets the reader thread advance
446/// the terminal in between, which pairs a grid from one moment with a title
447/// from another: a shell writes its prompt and then sets its title, so a
448/// snapshot of a screen that never changed again could still come out
449/// different each time.
450fn 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
992/// The window title the terminal is currently reporting.
993fn 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
1002/// Whether the title matches now. An unset title matches nothing, so `--not`
1003/// on a session that never set one succeeds.
1004fn 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
1078/// Naming the title actually seen turns "expected X" into a diff a caller can
1079/// act on, which matters more here than for text because the title is a single
1080/// short string that the terminal screen does not show.
1081fn 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    // The title is off by default: a shell prompt routinely sets it to a
1472    // username, hostname, and absolute path, which would pin every baseline to
1473    // one machine and make it change on `cd` while the screen stayed the same.
1474    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
1491/// Where to draw the cursor within `rows`, or `None` when the terminal is not
1492/// showing one.
1493///
1494/// `Emulator::cursor` is relative to the visible screen, so a full screenshot
1495/// has to push it down past the scrollback that precedes it.
1496fn 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    // Counted in `usize`: a full render is as long as the scrollback, which a
1506    // profile can set past what a `u16` row would hold, and a wrapped offset
1507    // draws the cursor on a plausible but wrong line.
1508    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
1535/// Capture everything the SVG renderer can observe while the emulator is
1536/// locked, then release the reader before doing the expensive string work.
1537fn 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        // Change every piece that used to be read after the grid lock was
1621        // released. Rendering the captured value must still show the old
1622        // character, visible cursor position, shape, and color.
1623        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}