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 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 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 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 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}