Skip to main content

tui_test/
api.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::shell::Shell;
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(default, deny_unknown_fields)]
9pub struct Timeouts {
10    pub text: Option<u64>,
11    pub idle: Option<u64>,
12    pub command: Option<u64>,
13    pub exit: Option<u64>,
14    pub ready: Option<u64>,
15}
16
17impl Timeouts {
18    pub fn get(&self, class: crate::config::TimeoutClass) -> Option<u64> {
19        use crate::config::TimeoutClass::*;
20        match class {
21            Text => self.text,
22            Idle => self.idle,
23            Command => self.command,
24            Exit => self.exit,
25            Ready => self.ready,
26        }
27    }
28
29    /// Apply higher-precedence timeout values over these defaults.
30    pub fn with_overrides(self, overrides: Self) -> Self {
31        Self {
32            text: overrides.text.or(self.text),
33            idle: overrides.idle.or(self.idle),
34            command: overrides.command.or(self.command),
35            exit: overrides.exit.or(self.exit),
36            ready: overrides.ready.or(self.ready),
37        }
38    }
39}
40
41#[derive(Debug, Clone)]
42pub struct OpenOptions {
43    pub backend: crate::terminal::backend::Backend,
44    pub shell: Option<Shell>,
45    /// Terminal settings, already resolved from the config file by the
46    /// client. The daemon never reads that file: it is long-lived and shared,
47    /// so it has no single working directory to resolve a project-local config
48    /// against.
49    pub profile: crate::profile::Profile,
50    pub cols: u16,
51    pub rows: u16,
52    pub cwd: Option<String>,
53    pub env: Vec<(String, String)>,
54    pub wait_ready: Option<bool>,
55    pub restart: bool,
56    pub timeouts: Timeouts,
57}
58
59impl Default for OpenOptions {
60    fn default() -> Self {
61        Self {
62            backend: crate::terminal::backend::Backend::default(),
63            shell: None,
64            profile: crate::profile::Profile::default(),
65            cols: crate::config::DEFAULT_COLS,
66            rows: crate::config::DEFAULT_ROWS,
67            cwd: None,
68            env: Vec::new(),
69            wait_ready: None,
70            restart: false,
71            timeouts: Timeouts::default(),
72        }
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct RunOptions {
78    pub backend: crate::terminal::backend::Backend,
79    pub program: String,
80    pub args: Vec<String>,
81    /// Terminal settings, already resolved from the config file by the
82    /// client. The daemon never reads that file: it is long-lived and shared,
83    /// so it has no single working directory to resolve a project-local config
84    /// against.
85    pub profile: crate::profile::Profile,
86    pub cols: u16,
87    pub rows: u16,
88    pub cwd: Option<String>,
89    pub env: Vec<(String, String)>,
90    pub wait_ready: Option<bool>,
91    pub restart: bool,
92    pub timeouts: Timeouts,
93}
94
95#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum KeyAction {
98    #[default]
99    Press,
100    Down,
101    Repeat,
102    Up,
103}
104
105#[derive(Debug, Clone)]
106pub enum Operation {
107    Open(OpenOptions),
108    Run(RunOptions),
109    Close,
110    State,
111    Text {
112        full: bool,
113    },
114    PackedScreen {
115        full: bool,
116    },
117    Cells {
118        x: u16,
119        y: u16,
120        w: u16,
121        h: u16,
122    },
123    GetCommand,
124    GetOutput,
125    GetExitCode,
126    GetCwd,
127    GetCursor,
128    GetSize,
129    GetTitle,
130    GetBellCount,
131    GetBellEvents,
132    Write {
133        data: String,
134    },
135    Submit {
136        data: Option<String>,
137    },
138    Key {
139        keys: Vec<String>,
140        action: KeyAction,
141    },
142    Mouse {
143        action: MouseAction,
144    },
145    Resize {
146        cols: u16,
147        rows: u16,
148    },
149    Signal {
150        name: String,
151    },
152    WaitText {
153        text: String,
154        regex: bool,
155        full: bool,
156        timeout_ms: Option<u64>,
157        not: bool,
158    },
159    WaitTitle {
160        text: String,
161        regex: bool,
162        timeout_ms: Option<u64>,
163        not: bool,
164    },
165    WaitIdle {
166        timeout_ms: Option<u64>,
167    },
168    WaitCommand {
169        timeout_ms: Option<u64>,
170    },
171    WaitExit {
172        timeout_ms: Option<u64>,
173    },
174    WaitReady {
175        timeout_ms: Option<u64>,
176    },
177    WaitBell {
178        timeout_ms: Option<u64>,
179    },
180    ExpectText {
181        text: String,
182        regex: bool,
183        full: bool,
184        strict: bool,
185        not: bool,
186        fg: Option<String>,
187        bg: Option<String>,
188        timeout_ms: Option<u64>,
189    },
190    ExpectTitle {
191        text: String,
192        regex: bool,
193        not: bool,
194        timeout_ms: Option<u64>,
195    },
196    ExpectExitCode {
197        code: i32,
198        timeout_ms: Option<u64>,
199    },
200    ExpectOutput {
201        text: String,
202        regex: bool,
203    },
204    ExpectBellCount {
205        count: u64,
206        timeout_ms: Option<u64>,
207    },
208    Snapshot {
209        name: String,
210        update: bool,
211        include_colors: bool,
212        include_title: bool,
213        cwd: Option<String>,
214    },
215    Screenshot {
216        full: bool,
217        path: Option<String>,
218        zoom: Option<f64>,
219    },
220    StartRecording {
221        path: String,
222        format: Option<RecordingFormat>,
223        fps: Option<u8>,
224        speed: Option<f64>,
225        idle_time_limit: Option<f64>,
226        zoom: Option<f64>,
227    },
228    StopRecording,
229}
230
231#[derive(Debug, Clone)]
232pub enum OperationResult {
233    Unit,
234    Open(OpenResult),
235    State(State),
236    Text(String),
237    PackedScreen(PackedScreen),
238    Cells(Vec<Cell>),
239    Command(Option<String>),
240    Output(Option<String>),
241    ExitCode(Option<i32>),
242    Cwd(Option<String>),
243    Title(Option<String>),
244    Cursor(Cursor),
245    Size(Size),
246    BellCount(u64),
247    BellEvents(Vec<BellEvent>),
248    Snapshot(SnapshotResult),
249    Screenshot(ScreenshotResult),
250    Recording(String),
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum ErrorKind {
256    Assertion,
257    Usage,
258    NoSession,
259    Internal,
260}
261
262impl ErrorKind {
263    pub fn exit_code(self) -> i32 {
264        match self {
265            ErrorKind::Assertion => 1,
266            ErrorKind::Usage => 2,
267            ErrorKind::NoSession => 3,
268            ErrorKind::Internal => 5,
269        }
270    }
271
272    pub fn as_str(self) -> &'static str {
273        match self {
274            ErrorKind::Assertion => "assertion",
275            ErrorKind::Usage => "usage",
276            ErrorKind::NoSession => "no_session",
277            ErrorKind::Internal => "internal",
278        }
279    }
280}
281
282#[derive(Debug, Clone)]
283pub struct TuiTestError {
284    pub kind: ErrorKind,
285    pub message: String,
286}
287
288impl TuiTestError {
289    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
290        Self {
291            kind,
292            message: message.into(),
293        }
294    }
295
296    pub fn assertion(message: impl Into<String>) -> Self {
297        Self::new(ErrorKind::Assertion, message)
298    }
299
300    pub fn usage(message: impl Into<String>) -> Self {
301        Self::new(ErrorKind::Usage, message)
302    }
303
304    pub fn no_session() -> Self {
305        Self::new(
306            ErrorKind::NoSession,
307            "no active session; run `tui-test open` (or `tui-test run <program>`) first",
308        )
309    }
310
311    pub fn internal(message: impl Into<String>) -> Self {
312        Self::new(ErrorKind::Internal, message)
313    }
314}
315
316impl fmt::Display for TuiTestError {
317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318        formatter.write_str(&self.message)
319    }
320}
321
322impl std::error::Error for TuiTestError {}
323
324#[derive(Debug, Clone, Serialize)]
325pub struct OpenResult {
326    pub shell_pid: Option<u32>,
327    pub session: String,
328    pub ready: bool,
329    pub recording: String,
330}
331
332#[derive(Debug, Clone, Copy, Serialize)]
333pub struct Cursor {
334    pub x: u16,
335    pub y: u16,
336}
337
338#[derive(Debug, Clone, Copy, Serialize)]
339pub struct Size {
340    pub cols: u16,
341    pub rows: u16,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
345pub struct BellEvent {
346    pub sequence: u64,
347    pub elapsed_ms: u64,
348}
349
350#[derive(Debug, Clone, Copy, Serialize)]
351pub struct EffectiveTimeouts {
352    pub text: u64,
353    pub idle: u64,
354    pub command: u64,
355    pub exit: u64,
356    pub ready: u64,
357}
358
359#[derive(Debug, Clone, Serialize)]
360pub struct State {
361    pub session_shell: Option<String>,
362    pub cols: u16,
363    pub rows: u16,
364    pub cursor: Cursor,
365    pub title: Option<String>,
366    pub cwd: Option<String>,
367    pub last_command: Option<String>,
368    pub last_exit: Option<i32>,
369    pub exited: Option<i32>,
370    pub ready: bool,
371    pub bell_count: u64,
372    pub timeouts: EffectiveTimeouts,
373    pub text: String,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq)]
377pub enum CellColor {
378    Default,
379    Indexed(u8),
380    Rgb(u8, u8, u8),
381}
382
383impl Serialize for CellColor {
384    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
385    where
386        S: serde::Serializer,
387    {
388        match self {
389            CellColor::Default => serializer.serialize_str("default"),
390            CellColor::Indexed(index) => serializer.serialize_u8(*index),
391            CellColor::Rgb(r, g, b) => serializer.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")),
392        }
393    }
394}
395
396#[derive(Debug, Clone, Serialize)]
397pub struct Cell {
398    pub x: u16,
399    pub y: u16,
400    pub char: String,
401    pub fg: CellColor,
402    pub bg: CellColor,
403    pub bold: bool,
404    pub dim: bool,
405    pub italic: bool,
406    pub inverse: bool,
407    pub invisible: bool,
408    pub strike: bool,
409    pub blink: bool,
410    pub underline: bool,
411    pub underline_style: String,
412    pub underline_color: CellColor,
413}
414
415#[derive(Debug, Clone)]
416pub struct PackedScreen {
417    /// Logical terminal dimensions for the newline-delimited UTF-8 snapshot.
418    /// Rows retain trailing spaces and blank lines; byte offsets are not cell
419    /// offsets because Unicode graphemes may occupy multiple bytes.
420    pub cols: u16,
421    pub rows: u16,
422    pub utf8: Vec<u8>,
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
426#[serde(rename_all = "lowercase")]
427pub enum SnapshotResult {
428    Passed,
429    Written,
430    Updated,
431}
432
433#[derive(Debug, Clone)]
434pub enum ScreenshotResult {
435    Path(String),
436    Text(String),
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(rename_all = "lowercase")]
441pub enum RecordingFormat {
442    Apng,
443    Gif,
444    Mp4,
445    Cast,
446}
447
448impl RecordingFormat {
449    pub fn infer(path: &str) -> Option<Self> {
450        let extension = std::path::Path::new(path)
451            .extension()?
452            .to_str()?
453            .to_ascii_lowercase();
454        match extension.as_str() {
455            "png" | "apng" => Some(Self::Apng),
456            "gif" => Some(Self::Gif),
457            "mp4" => Some(Self::Mp4),
458            "cast" => Some(Self::Cast),
459            _ => None,
460        }
461    }
462}
463
464pub(crate) fn resolve_zoom(zoom: Option<f64>) -> Result<f64, TuiTestError> {
465    let zoom = zoom.unwrap_or(1.0);
466    if !zoom.is_finite() || zoom <= 0.0 {
467        return Err(TuiTestError::usage(
468            "zoom must be finite and greater than zero",
469        ));
470    }
471    if zoom > f64::from(f32::MAX) / 2.0 {
472        return Err(TuiTestError::usage("zoom is too large"));
473    }
474    Ok(zoom)
475}
476
477#[derive(Debug, Clone, Serialize)]
478pub struct RuntimeStatus {
479    pub session: String,
480    pub shell_pid: Option<u32>,
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub cols: Option<u16>,
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub rows: Option<u16>,
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub shell: Option<String>,
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub exited: Option<i32>,
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub timeouts: Option<EffectiveTimeouts>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
494#[serde(tag = "op", rename_all = "snake_case")]
495pub enum MouseAction {
496    Click {
497        x: Option<u16>,
498        y: Option<u16>,
499        on_text: Option<String>,
500        button: u8,
501        clicks: u8,
502    },
503    Move {
504        x: u16,
505        y: u16,
506    },
507    Down {
508        x: u16,
509        y: u16,
510        button: u8,
511    },
512    Up {
513        x: u16,
514        y: u16,
515        button: u8,
516    },
517    Drag {
518        x1: u16,
519        y1: u16,
520        x2: u16,
521        y2: u16,
522        button: u8,
523    },
524    Scroll {
525        direction: String,
526        amount: u16,
527    },
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn recording_format_is_inferred_from_supported_extensions() {
536        assert_eq!(
537            RecordingFormat::infer("demo.png"),
538            Some(RecordingFormat::Apng)
539        );
540        assert_eq!(
541            RecordingFormat::infer("demo.APNG"),
542            Some(RecordingFormat::Apng)
543        );
544        assert_eq!(
545            RecordingFormat::infer("demo.gif"),
546            Some(RecordingFormat::Gif)
547        );
548        assert_eq!(
549            RecordingFormat::infer("demo.MP4"),
550            Some(RecordingFormat::Mp4)
551        );
552        assert_eq!(
553            RecordingFormat::infer("demo.cast"),
554            Some(RecordingFormat::Cast)
555        );
556        assert_eq!(RecordingFormat::infer("demo.webm"), None);
557    }
558
559    #[test]
560    fn zoom_defaults_to_one_and_rejects_invalid_values() {
561        assert_eq!(resolve_zoom(None).unwrap(), 1.0);
562        assert_eq!(resolve_zoom(Some(0.5)).unwrap(), 0.5);
563        for zoom in [0.0, -1.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
564            assert!(resolve_zoom(Some(zoom)).is_err());
565        }
566    }
567}