1use std::fmt;
2use std::path::PathBuf;
3
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::diagnostics::{FailureArtifactRef, FailureDetails, FailureObservation, FailureReport};
9use crate::shell::Shell;
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum AutomaticRecordingMode {
14 #[default]
15 Disabled,
16 OnFailure,
17 Always,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(default, deny_unknown_fields)]
22pub struct AutomaticRecording {
23 #[serde(skip)]
24 pub mode: AutomaticRecordingMode,
25 pub directory: Option<PathBuf>,
26}
27
28impl AutomaticRecording {
29 pub fn validate(&self) -> Result<(), TuiTestError> {
30 if self
31 .directory
32 .as_ref()
33 .is_some_and(|directory| directory.as_os_str().is_empty())
34 {
35 return Err(TuiTestError::usage(
36 "automatic recording directory must not be empty",
37 ));
38 }
39 Ok(())
40 }
41}
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct Timeouts {
46 pub text: Option<u64>,
47 pub idle: Option<u64>,
48 pub command: Option<u64>,
49 pub exit: Option<u64>,
50 pub ready: Option<u64>,
51}
52
53impl Timeouts {
54 pub fn get(&self, class: crate::config::TimeoutClass) -> Option<u64> {
55 use crate::config::TimeoutClass::*;
56 match class {
57 Text => self.text,
58 Idle => self.idle,
59 Command => self.command,
60 Exit => self.exit,
61 Ready => self.ready,
62 }
63 }
64
65 pub fn with_overrides(self, overrides: Self) -> Self {
67 Self {
68 text: overrides.text.or(self.text),
69 idle: overrides.idle.or(self.idle),
70 command: overrides.command.or(self.command),
71 exit: overrides.exit.or(self.exit),
72 ready: overrides.ready.or(self.ready),
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
78pub struct OpenOptions {
79 pub backend: crate::terminal::backend::Backend,
80 pub shell: Option<Shell>,
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 pub recording: AutomaticRecording,
94}
95
96impl Default for OpenOptions {
97 fn default() -> Self {
98 Self {
99 backend: crate::terminal::backend::Backend::default(),
100 shell: None,
101 profile: crate::profile::Profile::default(),
102 cols: crate::config::DEFAULT_COLS,
103 rows: crate::config::DEFAULT_ROWS,
104 cwd: None,
105 env: Vec::new(),
106 wait_ready: None,
107 restart: false,
108 timeouts: Timeouts::default(),
109 recording: AutomaticRecording::default(),
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
115pub struct RunOptions {
116 pub backend: crate::terminal::backend::Backend,
117 pub program: String,
118 pub args: Vec<String>,
119 pub profile: crate::profile::Profile,
124 pub cols: u16,
125 pub rows: u16,
126 pub cwd: Option<String>,
127 pub env: Vec<(String, String)>,
128 pub wait_ready: Option<bool>,
129 pub restart: bool,
130 pub timeouts: Timeouts,
131 pub recording: AutomaticRecording,
132}
133
134#[derive(Debug, Clone)]
136pub enum ClipboardPattern {
137 Text(String),
138 Regex(regex::Regex),
139}
140
141impl ClipboardPattern {
142 pub fn text(text: impl Into<String>) -> Self {
143 Self::Text(text.into())
144 }
145
146 pub fn regex(pattern: &str) -> Result<Self, regex::Error> {
147 regex::Regex::new(pattern).map(Self::Regex)
148 }
149
150 pub fn as_str(&self) -> &str {
151 match self {
152 Self::Text(text) => text,
153 Self::Regex(regex) => regex.as_str(),
154 }
155 }
156
157 pub(crate) fn matches(&self, value: &str) -> bool {
158 match self {
159 Self::Text(text) => value.contains(text),
160 Self::Regex(regex) => regex.is_match(value),
161 }
162 }
163}
164
165impl From<String> for ClipboardPattern {
166 fn from(text: String) -> Self {
167 Self::Text(text)
168 }
169}
170
171impl From<&str> for ClipboardPattern {
172 fn from(text: &str) -> Self {
173 Self::Text(text.to_string())
174 }
175}
176
177impl From<regex::Regex> for ClipboardPattern {
178 fn from(regex: regex::Regex) -> Self {
179 Self::Regex(regex)
180 }
181}
182
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "lowercase")]
185pub enum KeyAction {
186 #[default]
187 Press,
188 Down,
189 Repeat,
190 Up,
191}
192
193#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum MouseButton {
197 #[default]
198 Left,
199 Middle,
200 Right,
201}
202
203#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct MouseOptions {
209 pub button: MouseButton,
210 pub alt: bool,
211 pub ctrl: bool,
212 pub shift: bool,
213}
214
215impl MouseOptions {
216 pub const fn new(button: MouseButton) -> Self {
217 Self {
218 button,
219 alt: false,
220 ctrl: false,
221 shift: false,
222 }
223 }
224
225 pub const fn with_alt(mut self) -> Self {
226 self.alt = true;
227 self
228 }
229
230 pub const fn with_ctrl(mut self) -> Self {
231 self.ctrl = true;
232 self
233 }
234
235 pub const fn with_shift(mut self) -> Self {
236 self.shift = true;
237 self
238 }
239
240 pub const fn sgr_code(self) -> u8 {
242 let button = match self.button {
243 MouseButton::Left => 0,
244 MouseButton::Middle => 1,
245 MouseButton::Right => 2,
246 };
247 button + 4 * self.shift as u8 + 8 * self.alt as u8 + 16 * self.ctrl as u8
248 }
249
250 pub const fn from_sgr_code(code: u8) -> Option<Self> {
252 if code & !0b1_1111 != 0 {
253 return None;
254 }
255 let button = match code & 0b11 {
256 0 => MouseButton::Left,
257 1 => MouseButton::Middle,
258 2 => MouseButton::Right,
259 _ => return None,
260 };
261 Some(Self {
262 button,
263 shift: code & 4 != 0,
264 alt: code & 8 != 0,
265 ctrl: code & 16 != 0,
266 })
267 }
268}
269
270impl From<MouseButton> for MouseOptions {
271 fn from(button: MouseButton) -> Self {
272 Self::new(button)
273 }
274}
275
276mod mouse_options_code {
277 use serde::{Deserialize, Deserializer, Serializer};
278
279 use super::MouseOptions;
280
281 pub fn serialize<S>(options: &MouseOptions, serializer: S) -> Result<S::Ok, S::Error>
282 where
283 S: Serializer,
284 {
285 serializer.serialize_u8(options.sgr_code())
286 }
287
288 pub fn deserialize<'de, D>(deserializer: D) -> Result<MouseOptions, D::Error>
289 where
290 D: Deserializer<'de>,
291 {
292 let code = u8::deserialize(deserializer)?;
293 MouseOptions::from_sgr_code(code).ok_or_else(|| {
294 serde::de::Error::custom(format!("invalid SGR mouse button code {code}"))
295 })
296 }
297}
298
299#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(rename_all = "snake_case")]
301pub enum WhitespaceMode {
302 #[default]
303 Exact,
304 Normalize,
305}
306
307#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "snake_case")]
309pub enum MatchOccurrence {
310 Any,
311 #[default]
312 Unique,
313 First,
314 Last,
315 Nth(usize),
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319pub struct TextAnchor {
320 pub text: String,
321 #[serde(default)]
322 pub regex: bool,
323 #[serde(default)]
324 pub occurrence: MatchOccurrence,
325}
326
327#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(default)]
329pub struct TextScope {
330 pub after: Option<TextAnchor>,
331 pub before: Option<TextAnchor>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(default)]
336pub struct TextSelector {
337 pub text: String,
338 pub regex: bool,
339 pub full: bool,
340 pub whitespace: WhitespaceMode,
341 pub scope: TextScope,
342}
343
344impl Default for TextSelector {
345 fn default() -> Self {
346 Self {
347 text: String::new(),
348 regex: false,
349 full: false,
350 whitespace: WhitespaceMode::Exact,
351 scope: TextScope::default(),
352 }
353 }
354}
355
356impl TextSelector {
357 pub fn new(text: impl Into<String>) -> Self {
358 Self {
359 text: text.into(),
360 ..Self::default()
361 }
362 }
363}
364
365impl From<&str> for TextSelector {
366 fn from(text: &str) -> Self {
367 Self::new(text)
368 }
369}
370
371impl From<String> for TextSelector {
372 fn from(text: String) -> Self {
373 Self::new(text)
374 }
375}
376
377#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(default, deny_unknown_fields)]
379pub struct TextStyle {
380 pub foreground: Option<String>,
381 pub background: Option<String>,
382 pub bold: Option<bool>,
383 pub dim: Option<bool>,
384 pub italic: Option<bool>,
385 pub underline_style: Option<String>,
386 pub underline_color: Option<String>,
387 pub inverse: Option<bool>,
388 pub hidden: Option<bool>,
389 pub strikethrough: Option<bool>,
390 pub blink: Option<bool>,
391}
392
393impl TextStyle {
394 pub fn is_empty(&self) -> bool {
395 self == &Self::default()
396 }
397}
398
399#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
400#[serde(default, deny_unknown_fields)]
401pub struct StyleSelector {
403 pub style: TextStyle,
404 pub full: bool,
405}
406
407impl From<TextStyle> for StyleSelector {
408 fn from(style: TextStyle) -> Self {
409 Self {
410 style,
411 ..Self::default()
412 }
413 }
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(
418 tag = "kind",
419 content = "selector",
420 rename_all = "snake_case",
421 deny_unknown_fields
422)]
423pub enum LocatorSelector {
424 Text(TextSelector),
425 Style(StyleSelector),
426 Link(LinkSelector),
427 And {
428 left: Box<LocatorQuery>,
429 right: Box<LocatorQuery>,
430 },
431 Or {
432 left: Box<LocatorQuery>,
433 right: Box<LocatorQuery>,
434 },
435 Filter {
436 input: Box<LocatorQuery>,
437 has: Option<Box<LocatorQuery>>,
438 has_not: Option<Box<LocatorQuery>>,
439 },
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(deny_unknown_fields)]
445pub struct LinkSelector {
446 pub uri: String,
447 #[serde(default)]
448 pub full: bool,
449}
450
451impl From<&str> for LinkSelector {
452 fn from(uri: &str) -> Self {
453 Self::from(uri.to_owned())
454 }
455}
456
457impl From<String> for LinkSelector {
458 fn from(uri: String) -> Self {
459 Self { uri, full: false }
460 }
461}
462
463impl LocatorSelector {
464 pub fn full(&self) -> bool {
465 match self {
466 Self::Text(selector) => selector.full,
467 Self::Style(selector) => selector.full,
468 Self::Link(selector) => selector.full,
469 _ => self.children().iter().any(|query| query.uses_full_grid()),
470 }
471 }
472
473 pub fn description(&self) -> String {
474 match self {
475 Self::Text(selector) => selector.text.clone(),
476 Self::Style(_) => "style".to_string(),
477 Self::Link(selector) => format!("link {:?}", selector.uri),
478 Self::And { left, right } => format!(
479 "({}) and ({})",
480 left.selector.description(),
481 right.selector.description()
482 ),
483 Self::Or { left, right } => format!(
484 "({}) or ({})",
485 left.selector.description(),
486 right.selector.description()
487 ),
488 Self::Filter {
489 input,
490 has,
491 has_not,
492 } => {
493 let mut description = input.selector.description();
494 if let Some(has) = has {
495 description.push_str(&format!(" has ({})", has.selector.description()));
496 }
497 if let Some(has_not) = has_not {
498 description.push_str(&format!(" has not ({})", has_not.selector.description()));
499 }
500 description
501 }
502 }
503 }
504
505 pub fn children(&self) -> Vec<&LocatorQuery> {
506 match self {
507 Self::And { left, right } | Self::Or { left, right } => vec![left, right],
508 Self::Filter {
509 input,
510 has,
511 has_not,
512 } => {
513 let mut children = vec![input.as_ref()];
514 children.extend(has.as_deref());
515 children.extend(has_not.as_deref());
516 children
517 }
518 _ => Vec::new(),
519 }
520 }
521}
522
523#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
524#[serde(rename_all = "snake_case")]
525pub enum LocatorDirection {
526 #[default]
528 Within,
529 After,
531 Before,
533}
534
535fn default_locator_occurrence() -> MatchOccurrence {
536 MatchOccurrence::Any
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541pub struct LocatorQuery {
543 pub selector: LocatorSelector,
544 #[serde(default = "default_locator_occurrence")]
545 pub occurrence: MatchOccurrence,
546 #[serde(default)]
547 pub within: Option<Box<LocatorQuery>>,
548 #[serde(default)]
549 pub direction: LocatorDirection,
550 #[serde(default)]
551 pub style: TextStyle,
552}
553
554impl LocatorQuery {
555 pub fn new(selector: LocatorSelector) -> Self {
556 Self {
557 selector,
558 occurrence: MatchOccurrence::Any,
559 within: None,
560 direction: LocatorDirection::Within,
561 style: TextStyle::default(),
562 }
563 }
564
565 pub fn link(selector: impl Into<LinkSelector>) -> Self {
566 Self::new(LocatorSelector::Link(selector.into()))
567 }
568
569 pub fn and(self, other: Self) -> Self {
570 Self::new(LocatorSelector::And {
571 left: Box::new(self),
572 right: Box::new(other),
573 })
574 }
575
576 pub fn or(self, other: Self) -> Self {
577 Self::new(LocatorSelector::Or {
578 left: Box::new(self),
579 right: Box::new(other),
580 })
581 }
582
583 pub fn filter(self, has: Option<Self>, has_not: Option<Self>) -> Self {
584 Self::new(LocatorSelector::Filter {
585 input: Box::new(self),
586 has: has.map(Box::new),
587 has_not: has_not.map(Box::new),
588 })
589 }
590
591 pub fn text(selector: impl Into<TextSelector>) -> Self {
592 Self {
593 selector: LocatorSelector::Text(selector.into()),
594 occurrence: MatchOccurrence::Any,
595 within: None,
596 direction: LocatorDirection::Within,
597 style: TextStyle::default(),
598 }
599 }
600
601 pub fn style(selector: impl Into<StyleSelector>) -> Self {
602 Self {
603 selector: LocatorSelector::Style(selector.into()),
604 occurrence: MatchOccurrence::Any,
605 within: None,
606 direction: LocatorDirection::Within,
607 style: TextStyle::default(),
608 }
609 }
610
611 pub fn uses_full_grid(&self) -> bool {
612 self.selector.full()
613 || self
614 .within
615 .as_deref()
616 .is_some_and(LocatorQuery::uses_full_grid)
617 }
618}
619
620#[derive(Debug, Clone)]
621pub enum Operation {
622 Open(OpenOptions),
623 Run(RunOptions),
624 Restart {
625 graceful_timeout_ms: u64,
626 },
627 Close,
628 FinishTrace {
629 failed: bool,
630 },
631 State,
632 Text {
633 full: bool,
634 },
635 PackedScreen {
636 full: bool,
637 },
638 Cells {
639 x: u16,
640 y: u16,
641 w: u16,
642 h: u16,
643 },
644 GetCommand,
645 GetOutput,
646 GetExitCode,
647 GetCwd,
648 GetCursor,
649 GetModes,
650 GetColors,
651 GetSize,
652 GetTitle,
653 GetClipboard,
654 GetBellCount,
655 GetBellEvents,
656 Write {
657 data: String,
658 },
659 Submit {
660 data: Option<String>,
661 },
662 Key {
663 keys: Vec<String>,
664 action: KeyAction,
665 },
666 Mouse {
667 action: MouseAction,
668 },
669 Resize {
670 cols: u16,
671 rows: u16,
672 },
673 Signal {
674 name: String,
675 },
676 WaitTitle {
677 text: String,
678 regex: bool,
679 timeout_ms: Option<u64>,
680 not: bool,
681 },
682 WaitClipboard {
683 timeout_ms: Option<u64>,
684 },
685 WaitClipboardMatch {
686 pattern: ClipboardPattern,
687 timeout_ms: Option<u64>,
688 },
689 WaitIdle {
690 timeout_ms: Option<u64>,
691 },
692 WaitCommand {
693 timeout_ms: Option<u64>,
694 },
695 WaitExit {
696 timeout_ms: Option<u64>,
697 },
698 WaitReady {
699 timeout_ms: Option<u64>,
700 },
701 WaitBell {
702 timeout_ms: Option<u64>,
703 },
704 FindLocator {
705 query: LocatorQuery,
706 },
707 ResolveLocator {
708 query: LocatorQuery,
709 },
710 WaitLocator {
711 query: LocatorQuery,
712 not: bool,
713 timeout_ms: Option<u64>,
714 },
715 ClickLocator {
716 query: LocatorQuery,
717 options: MouseOptions,
718 clicks: u8,
719 timeout_ms: Option<u64>,
720 },
721 HighlightLocator {
722 query: LocatorQuery,
723 timeout_ms: Option<u64>,
724 },
725 ExpectTitle {
726 text: String,
727 regex: bool,
728 not: bool,
729 timeout_ms: Option<u64>,
730 },
731 ExpectExitCode {
732 code: i32,
733 timeout_ms: Option<u64>,
734 },
735 ExpectMode {
737 mode: String,
738 enabled: bool,
739 timeout_ms: Option<u64>,
740 },
741 ExpectColors {
743 foreground: Option<String>,
744 background: Option<String>,
745 cursor: Option<String>,
746 palette: Vec<(u8, String)>,
748 timeout_ms: Option<u64>,
749 },
750 ExpectCursor {
752 visible: Option<bool>,
753 shape: Option<String>,
754 x: Option<u16>,
755 y: Option<u16>,
756 timeout_ms: Option<u64>,
757 },
758 ExpectOutput {
759 text: String,
760 regex: bool,
761 },
762 ExpectBellCount {
763 count: u64,
764 timeout_ms: Option<u64>,
765 },
766 Snapshot {
767 name: String,
768 update: bool,
769 include_style: bool,
770 include_title: bool,
771 cwd: Option<String>,
772 },
773 Screenshot {
774 full: bool,
775 path: Option<String>,
776 zoom: Option<f64>,
777 background: Option<CaptureBackground>,
778 },
779 StartRecording {
780 path: String,
781 format: Option<RecordingFormat>,
782 fps: Option<u8>,
783 speed: Option<f64>,
784 idle_time_limit: Option<f64>,
785 zoom: Option<f64>,
786 background: Option<CaptureBackground>,
787 },
788 StopRecording,
789}
790
791impl Operation {
792 pub fn wait_clipboard_match(
794 pattern: impl Into<ClipboardPattern>,
795 timeout_ms: Option<u64>,
796 ) -> Self {
797 Self::WaitClipboardMatch {
798 pattern: pattern.into(),
799 timeout_ms,
800 }
801 }
802}
803
804#[derive(Debug, Clone)]
805pub enum OperationResult {
806 Unit,
807 Open(OpenResult),
808 State(Box<State>),
811 Text(String),
812 PackedScreen(PackedScreen),
813 Cells(Vec<Cell>),
814 Matches(Vec<TextMatch>),
815 Command(Option<String>),
816 Output(Option<String>),
817 ExitCode(Option<i32>),
818 Cwd(Option<String>),
819 Title(Option<String>),
820 Clipboard(String),
821 Cursor(Cursor),
822 Modes(BTreeMap<String, bool>),
823 Colors(TerminalColors),
824 Size(Size),
825 BellCount(u64),
826 BellEvents(Vec<BellEvent>),
827 Snapshot(SnapshotResult),
828 Screenshot(ScreenshotResult),
829 Recording(String),
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
833#[serde(rename_all = "snake_case")]
834pub enum ErrorKind {
835 Assertion,
836 Usage,
837 NoSession,
838 Internal,
839}
840
841impl ErrorKind {
842 pub fn exit_code(self) -> i32 {
843 match self {
844 ErrorKind::Assertion => 1,
845 ErrorKind::Usage => 2,
846 ErrorKind::NoSession => 3,
847 ErrorKind::Internal => 5,
848 }
849 }
850
851 pub fn as_str(self) -> &'static str {
852 match self {
853 ErrorKind::Assertion => "assertion",
854 ErrorKind::Usage => "usage",
855 ErrorKind::NoSession => "no_session",
856 ErrorKind::Internal => "internal",
857 }
858 }
859}
860
861#[derive(Debug, Clone)]
862#[non_exhaustive]
863pub struct TuiTestError {
864 pub kind: ErrorKind,
865 pub message: String,
866 pub details: Option<Box<FailureDetails>>,
867 pub artifact: Option<Box<FailureArtifactRef>>,
868 pub(crate) report: Option<Box<FailureReport>>,
869 pub(crate) observation: Option<Box<FailureObservation>>,
870}
871
872impl TuiTestError {
873 pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
874 Self {
875 kind,
876 message: message.into(),
877 details: None,
878 artifact: None,
879 report: None,
880 observation: None,
881 }
882 }
883
884 pub fn assertion(message: impl Into<String>) -> Self {
885 Self::new(ErrorKind::Assertion, message)
886 }
887
888 pub fn usage(message: impl Into<String>) -> Self {
889 Self::new(ErrorKind::Usage, message)
890 }
891
892 pub fn no_session() -> Self {
893 Self::new(
894 ErrorKind::NoSession,
895 "no active session; run `tui-test open` (or `tui-test run <program>`) first",
896 )
897 }
898
899 pub fn no_restart_metadata() -> Self {
900 Self::new(
901 ErrorKind::NoSession,
902 "session has no restart metadata; run `tui-test open` or \
903 `tui-test run <program>` before `tui-test restart`",
904 )
905 }
906
907 pub fn internal(message: impl Into<String>) -> Self {
908 Self::new(ErrorKind::Internal, message)
909 }
910
911 pub fn with_details(mut self, details: FailureDetails) -> Self {
912 self.details = Some(Box::new(details));
913 self
914 }
915
916 pub fn with_artifact(mut self, artifact: FailureArtifactRef) -> Self {
917 self.artifact = Some(Box::new(artifact));
918 self
919 }
920
921 pub(crate) fn with_report(mut self, report: FailureReport) -> Self {
922 self.report = Some(Box::new(report));
923 self
924 }
925}
926
927impl fmt::Display for TuiTestError {
928 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
929 formatter.write_str(&self.message)
930 }
931}
932
933impl std::error::Error for TuiTestError {}
934
935#[derive(Debug, Clone, Serialize)]
936pub struct OpenResult {
937 pub shell_pid: Option<u32>,
938 pub session: String,
939 pub ready: bool,
940 pub recording: String,
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
951pub struct TerminalColors {
952 pub foreground: String,
954 pub background: String,
956 pub cursor: String,
958 pub palette: std::collections::BTreeMap<u8, String>,
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize)]
967pub struct Cursor {
968 pub x: u16,
969 pub y: u16,
970 pub visible: bool,
972 pub shape: String,
974 pub color: String,
976}
977
978#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
979pub struct Size {
980 pub cols: u16,
981 pub rows: u16,
982}
983
984#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
985pub struct BellEvent {
986 pub sequence: u64,
987 pub elapsed_ms: u64,
988}
989
990#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
991pub struct TextPosition {
992 pub row: u32,
993 pub column: u16,
994}
995
996#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
997pub struct TextSpan {
998 pub row: u32,
999 pub start: u16,
1000 pub end: u16,
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1004pub struct TextMatch {
1005 pub text: String,
1006 pub start: TextPosition,
1007 pub end: TextPosition,
1009 pub spans: Vec<TextSpan>,
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1014pub struct EffectiveTimeouts {
1015 pub text: u64,
1016 pub idle: u64,
1017 pub command: u64,
1018 pub exit: u64,
1019 pub ready: u64,
1020}
1021
1022#[derive(Debug, Clone, Serialize)]
1023pub struct State {
1024 pub session_shell: Option<String>,
1025 pub cols: u16,
1026 pub rows: u16,
1027 pub cursor: Cursor,
1028 pub title: Option<String>,
1029 pub cwd: Option<String>,
1030 pub last_command: Option<String>,
1031 pub last_exit: Option<i32>,
1032 pub exit_signal: Option<String>,
1034 pub exited: Option<i32>,
1035 pub ready: bool,
1036 pub bell_count: u64,
1037 pub modes: BTreeMap<String, bool>,
1042 pub mouse_mode: String,
1053 pub colors: TerminalColors,
1056 pub timeouts: EffectiveTimeouts,
1057 pub text: String,
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Eq)]
1061pub enum CellColor {
1062 Default,
1063 Indexed(u8),
1064 Rgb(u8, u8, u8),
1065}
1066
1067impl Serialize for CellColor {
1068 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1069 where
1070 S: serde::Serializer,
1071 {
1072 match self {
1073 CellColor::Default => serializer.serialize_str("default"),
1074 CellColor::Indexed(index) => serializer.serialize_u8(*index),
1075 CellColor::Rgb(r, g, b) => serializer.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")),
1076 }
1077 }
1078}
1079
1080#[derive(Debug, Clone, Serialize)]
1081pub struct Cell {
1082 pub x: u16,
1083 pub y: u16,
1084 pub char: String,
1085 pub fg: CellColor,
1086 pub bg: CellColor,
1087 pub bold: bool,
1088 pub dim: bool,
1089 pub italic: bool,
1090 pub inverse: bool,
1091 pub invisible: bool,
1092 pub strike: bool,
1093 pub blink: bool,
1094 pub underline: bool,
1095 pub underline_style: String,
1096 pub underline_color: CellColor,
1097 pub link: String,
1099 pub link_id: String,
1112}
1113
1114#[derive(Debug, Clone)]
1115pub struct PackedScreen {
1116 pub cols: u16,
1120 pub rows: u16,
1121 pub utf8: Vec<u8>,
1122}
1123
1124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1125#[serde(rename_all = "lowercase")]
1126pub enum SnapshotResult {
1127 Passed,
1128 Written,
1129 Updated,
1130}
1131
1132#[derive(Debug, Clone)]
1133pub enum ScreenshotResult {
1134 Path(String),
1135 Text(String),
1136}
1137
1138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1139#[serde(rename_all = "lowercase")]
1140pub enum RecordingFormat {
1141 Apng,
1142 Gif,
1143 Mp4,
1144 Cast,
1145}
1146
1147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1148#[serde(rename_all = "snake_case", tag = "kind", content = "color")]
1149pub enum CaptureBackground {
1150 Color(crate::profile::Rgb),
1151 Transparent,
1152}
1153
1154impl CaptureBackground {
1155 pub fn parse(value: &str) -> Result<Self, TuiTestError> {
1156 if value.eq_ignore_ascii_case("transparent") {
1157 return Ok(Self::Transparent);
1158 }
1159 crate::profile::Rgb::parse(value)
1160 .map(Self::Color)
1161 .map_err(TuiTestError::usage)
1162 }
1163}
1164
1165impl RecordingFormat {
1166 pub fn infer(path: &str) -> Option<Self> {
1167 let extension = std::path::Path::new(path)
1168 .extension()?
1169 .to_str()?
1170 .to_ascii_lowercase();
1171 match extension.as_str() {
1172 "png" | "apng" => Some(Self::Apng),
1173 "gif" => Some(Self::Gif),
1174 "mp4" => Some(Self::Mp4),
1175 "cast" => Some(Self::Cast),
1176 _ => None,
1177 }
1178 }
1179}
1180
1181pub(crate) fn resolve_zoom(zoom: Option<f64>) -> Result<f64, TuiTestError> {
1182 let zoom = zoom.unwrap_or(1.0);
1183 if !zoom.is_finite() || zoom <= 0.0 {
1184 return Err(TuiTestError::usage(
1185 "zoom must be finite and greater than zero",
1186 ));
1187 }
1188 if zoom > f64::from(f32::MAX) / 2.0 {
1189 return Err(TuiTestError::usage("zoom is too large"));
1190 }
1191 Ok(zoom)
1192}
1193
1194#[derive(Debug, Clone, Serialize)]
1195pub struct RuntimeStatus {
1196 pub session: String,
1197 pub shell_pid: Option<u32>,
1198 #[serde(skip_serializing_if = "Option::is_none")]
1199 pub cols: Option<u16>,
1200 #[serde(skip_serializing_if = "Option::is_none")]
1201 pub rows: Option<u16>,
1202 #[serde(skip_serializing_if = "Option::is_none")]
1203 pub shell: Option<String>,
1204 #[serde(skip_serializing_if = "Option::is_none")]
1205 pub exited: Option<i32>,
1206 #[serde(skip_serializing_if = "Option::is_none")]
1207 pub timeouts: Option<EffectiveTimeouts>,
1208}
1209
1210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1211#[serde(tag = "op", rename_all = "snake_case")]
1212pub enum MouseAction {
1213 Click {
1214 x: Option<u16>,
1215 y: Option<u16>,
1216 on_text: Option<String>,
1217 #[serde(default, rename = "button", with = "mouse_options_code")]
1218 options: MouseOptions,
1219 clicks: u8,
1220 },
1221 Move {
1222 x: u16,
1223 y: u16,
1224 },
1225 Down {
1226 x: u16,
1227 y: u16,
1228 #[serde(default, rename = "button", with = "mouse_options_code")]
1229 options: MouseOptions,
1230 },
1231 Up {
1232 x: u16,
1233 y: u16,
1234 #[serde(default, rename = "button", with = "mouse_options_code")]
1235 options: MouseOptions,
1236 },
1237 Drag {
1238 x1: u16,
1239 y1: u16,
1240 x2: u16,
1241 y2: u16,
1242 #[serde(default, rename = "button", with = "mouse_options_code")]
1243 options: MouseOptions,
1244 },
1245 Scroll {
1246 direction: String,
1247 amount: u16,
1248 },
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253 use super::*;
1254
1255 #[test]
1256 fn capture_background_accepts_hex_rgb_and_transparency() {
1257 for (value, expected) in [
1258 ("#1aF", "#11aaff"),
1259 ("123456", "#123456"),
1260 (" #AbCdEf ", "#abcdef"),
1261 ("#000000", "#000000"),
1262 ("#ffffff", "#ffffff"),
1263 ] {
1264 let background = CaptureBackground::parse(value).unwrap();
1265 assert_eq!(
1266 background,
1267 CaptureBackground::Color(crate::profile::Rgb::parse(expected).unwrap()),
1268 "{value:?}"
1269 );
1270 let json = serde_json::to_string(&background).unwrap();
1271 assert_eq!(
1272 serde_json::from_str::<CaptureBackground>(&json).unwrap(),
1273 background
1274 );
1275 }
1276 for value in ["transparent", "TRANSPARENT"] {
1277 assert_eq!(
1278 CaptureBackground::parse(value).unwrap(),
1279 CaptureBackground::Transparent
1280 );
1281 }
1282 }
1283
1284 #[test]
1285 fn invalid_capture_background_colors_are_usage_errors() {
1286 for value in [
1287 "",
1288 " ",
1289 "#",
1290 "#12",
1291 "#1234",
1292 "#12345",
1293 "#1234567",
1294 "#12345678",
1295 "#ggg",
1296 "#ff00zz",
1297 "##fff",
1298 "#-12345",
1299 "#12é34",
1300 "123",
1301 "256,0,0",
1302 "-1,0,0",
1303 "1,2",
1304 "1,2,3,4",
1305 "1.5,0,0",
1306 "rgb(256,0,0)",
1307 ] {
1308 let error = CaptureBackground::parse(value).unwrap_err();
1309 assert_eq!(error.kind, ErrorKind::Usage, "{value:?}");
1310 assert!(error.message.contains("color"), "{value:?}: {error}");
1311 }
1312 }
1313
1314 #[test]
1315 fn clipboard_patterns_infer_matching_from_the_rust_type() {
1316 let literal: ClipboardPattern = "ready".into();
1317 assert!(literal.matches("prefix-ready-suffix"));
1318
1319 let regex: ClipboardPattern = regex::Regex::new(r"^build-[0-9]+$").unwrap().into();
1320 assert!(regex.matches("build-123"));
1321 assert!(!regex.matches("prefix-build-123"));
1322
1323 assert!(matches!(
1324 Operation::wait_clipboard_match("ready", Some(5_000)),
1325 Operation::WaitClipboardMatch { .. }
1326 ));
1327 }
1328
1329 #[test]
1330 fn recording_format_is_inferred_from_supported_extensions() {
1331 assert_eq!(
1332 RecordingFormat::infer("demo.png"),
1333 Some(RecordingFormat::Apng)
1334 );
1335 assert_eq!(
1336 RecordingFormat::infer("demo.APNG"),
1337 Some(RecordingFormat::Apng)
1338 );
1339 assert_eq!(
1340 RecordingFormat::infer("demo.gif"),
1341 Some(RecordingFormat::Gif)
1342 );
1343 assert_eq!(
1344 RecordingFormat::infer("demo.MP4"),
1345 Some(RecordingFormat::Mp4)
1346 );
1347 assert_eq!(
1348 RecordingFormat::infer("demo.cast"),
1349 Some(RecordingFormat::Cast)
1350 );
1351 assert_eq!(RecordingFormat::infer("demo.webm"), None);
1352 }
1353
1354 #[test]
1355 fn mouse_options_are_semantic_and_wire_compatible() {
1356 let options = MouseOptions::new(MouseButton::Middle)
1357 .with_ctrl()
1358 .with_shift();
1359 assert_eq!(options.sgr_code(), 21);
1360 assert_eq!(MouseOptions::from_sgr_code(21), Some(options));
1361 assert_eq!(MouseOptions::from_sgr_code(3), None);
1362 assert_eq!(MouseOptions::from_sgr_code(32), None);
1363 assert_eq!(serde_json::to_value(options).unwrap()["button"], "middle");
1364
1365 let action = MouseAction::Down {
1366 x: 4,
1367 y: 7,
1368 options,
1369 };
1370 let value = serde_json::to_value(&action).unwrap();
1371 assert_eq!(value["button"], 21);
1372 assert_eq!(
1373 serde_json::from_value::<MouseAction>(value).unwrap(),
1374 action
1375 );
1376
1377 let defaulted: MouseAction = serde_json::from_str(r#"{"op":"down","x":4,"y":7}"#).unwrap();
1378 assert_eq!(
1379 defaulted,
1380 MouseAction::Down {
1381 x: 4,
1382 y: 7,
1383 options: MouseOptions::default(),
1384 }
1385 );
1386 }
1387
1388 #[test]
1389 fn zoom_defaults_to_one_and_rejects_invalid_values() {
1390 assert_eq!(resolve_zoom(None).unwrap(), 1.0);
1391 assert_eq!(resolve_zoom(Some(0.5)).unwrap(), 0.5);
1392 for zoom in [0.0, -1.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
1393 assert!(resolve_zoom(Some(zoom)).is_err());
1394 }
1395 }
1396
1397 #[test]
1398 fn nested_selectors_use_full_grid_when_any_stage_requests_it() {
1399 let mut parent = TextSelector::new("parent");
1400 parent.full = true;
1401 let child = LocatorQuery {
1402 selector: LocatorSelector::Text(TextSelector::new("child")),
1403 occurrence: MatchOccurrence::Any,
1404 within: Some(Box::new(LocatorQuery::text(parent))),
1405 direction: LocatorDirection::Within,
1406 style: Default::default(),
1407 };
1408 assert!(child.uses_full_grid());
1409
1410 let mut full_child = TextSelector::new("child");
1411 full_child.full = true;
1412 let query = LocatorQuery {
1413 selector: LocatorSelector::Text(full_child),
1414 occurrence: MatchOccurrence::Any,
1415 within: Some(Box::new(LocatorQuery::text("parent"))),
1416 direction: LocatorDirection::Within,
1417 style: Default::default(),
1418 };
1419 assert!(query.uses_full_grid());
1420 }
1421}