1#[cfg(not(target_arch = "wasm32"))]
14use anyhow::{anyhow, Result};
15#[cfg(not(target_arch = "wasm32"))]
16use base64::Engine;
17use serde::{Deserialize, Serialize};
18
19pub mod golden;
20pub use golden::{compare_png_to_golden, GoldenOptions, GoldenReport};
21
22#[cfg(not(target_arch = "wasm32"))]
23pub mod browser;
24#[cfg(not(target_arch = "wasm32"))]
25pub use browser::{
26 detect_chrome, run_browser_smoke, BrowserSmokeMode, BrowserSmokeReport, BrowserTestOptions,
27};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "cmd")]
37pub enum TestCommand {
38 Tap {
39 x: f32,
40 y: f32,
41 },
42 Drag {
43 start_x: f32,
44 start_y: f32,
45 end_x: f32,
46 end_y: f32,
47 steps: u32,
48 },
49 TapText {
50 text: String,
51 },
52 ResolveSelector {
53 query: SelectorQuery,
54 },
55 TapSelector {
56 query: SelectorQuery,
57 },
58 ActivateSelector {
59 query: SelectorQuery,
60 },
61 FocusSelector {
62 query: SelectorQuery,
63 },
64 HoverSelector {
65 query: SelectorQuery,
66 },
67 RightClickSelector {
68 query: SelectorQuery,
69 },
70 ScrollIntoView {
71 query: SelectorQuery,
72 },
73 FillText {
74 query: SelectorQuery,
75 text: String,
76 },
77 ClearText {
78 query: SelectorQuery,
79 },
80 Toggle {
81 query: SelectorQuery,
82 },
83 SelectOption {
84 query: SelectorQuery,
85 },
86 WaitForSelector {
87 query: SelectorQuery,
88 timeout_ms: u64,
89 },
90 WaitForVisible {
91 query: SelectorQuery,
92 timeout_ms: u64,
93 },
94 WaitForEnabled {
95 query: SelectorQuery,
96 timeout_ms: u64,
97 },
98 WaitForDisabled {
99 query: SelectorQuery,
100 timeout_ms: u64,
101 },
102 WaitForValue {
103 query: SelectorQuery,
104 value: String,
105 timeout_ms: u64,
106 },
107 WaitForText {
108 text: String,
109 timeout_ms: u64,
110 },
111 WaitForGone {
112 query: SelectorQuery,
113 timeout_ms: u64,
114 },
115 Scroll {
116 x: f32,
117 y: f32,
118 dx: f32,
119 dy: f32,
120 },
121 ExternalFileHover {
122 x: f32,
123 y: f32,
124 paths: Vec<String>,
125 },
126 ExternalFileDrop {
127 x: f32,
128 y: f32,
129 paths: Vec<String>,
130 },
131 ExternalFileCancel {},
132 TypeText {
133 text: String,
134 },
135 ImePreedit {
136 text: String,
137 cursor_start: Option<usize>,
138 cursor_end: Option<usize>,
139 },
140 ImeCommit {
141 text: String,
142 },
143 ImeCancel {},
144 PressKey {
145 key: String,
146 modifiers: u8,
147 },
148 Screenshot {
149 path: String,
150 },
151 CaptureScreenshot {},
152 PauseAnimations {},
153 ResumeAnimations {},
154 AdvanceClock {
155 ms: u64,
156 },
157 CaptureAt {
158 ms: u64,
159 },
160 WaitForIdle {
161 timeout_ms: u64,
162 ignore_repeating_motion: bool,
163 },
164 GetText {},
165 GetTree {},
166 Wait {
167 ms: u64,
168 },
169 Pump {},
170 Quit {},
171 SimulateMouseMove {
173 x: f32,
174 y: f32,
175 },
176 SimulateRightClick {
177 x: f32,
178 y: f32,
179 },
180 SimulateResize {
181 width: u32,
183 height: u32,
185 },
186}
187
188#[derive(Debug, Clone)]
197pub enum TestEvent {
198 MouseMove {
200 x: f32,
201 y: f32,
202 },
203 MouseDown {
204 x: f32,
205 y: f32,
206 button: u8,
207 }, MouseUp {
209 x: f32,
210 y: f32,
211 button: u8,
212 },
213 KeyDown {
214 key_code: String,
215 modifiers: u8,
216 },
217 KeyUp {
218 key_code: String,
219 modifiers: u8,
220 },
221 TextInput {
222 text: String,
223 },
224 ImePreedit {
225 text: String,
226 cursor: Option<(usize, usize)>,
227 },
228 ImeCommit {
229 text: String,
230 },
231 ImeCancel,
232 Scroll {
233 x: f32,
234 y: f32,
235 dx: f32,
236 dy: f32,
237 },
238 ExternalFileHover {
239 x: f32,
240 y: f32,
241 paths: Vec<String>,
242 },
243 ExternalFileDrop {
244 x: f32,
245 y: f32,
246 paths: Vec<String>,
247 },
248 ExternalFileCancel,
249 Resize {
250 width: u32,
251 height: u32,
252 },
253 Screenshot {
255 path: String,
256 response_tx: TestResponseSender,
257 },
258 CaptureScreenshot {
259 response_tx: TestResponseSender,
260 },
261 PauseAnimations {
262 response_tx: TestResponseSender,
263 },
264 ResumeAnimations {
265 response_tx: TestResponseSender,
266 },
267 AdvanceClock {
268 ms: u64,
269 response_tx: TestResponseSender,
270 },
271 CaptureAt {
272 ms: u64,
273 response_tx: TestResponseSender,
274 },
275 MotionStatus {
276 response_tx: TestResponseSender,
277 },
278 GetText {
279 response_tx: TestResponseSender,
280 },
281 GetTree {
282 response_tx: TestResponseSender,
283 },
284 Pump {
285 response_tx: TestResponseSender,
286 },
287 Wake,
288 Quit,
289 TapText {
292 text: String,
293 response_tx: TestResponseSender,
294 },
295 ResolveSelector {
296 query: SelectorQuery,
297 response_tx: TestResponseSender,
298 },
299 TapSelector {
300 query: SelectorQuery,
301 response_tx: TestResponseSender,
302 },
303 ActivateSelector {
304 query: SelectorQuery,
305 response_tx: TestResponseSender,
306 },
307 FocusSelector {
308 query: SelectorQuery,
309 response_tx: TestResponseSender,
310 },
311 HoverSelector {
312 query: SelectorQuery,
313 response_tx: TestResponseSender,
314 },
315 RightClickSelector {
316 query: SelectorQuery,
317 response_tx: TestResponseSender,
318 },
319 ScrollIntoView {
320 query: SelectorQuery,
321 response_tx: TestResponseSender,
322 },
323 FillText {
324 query: SelectorQuery,
325 text: String,
326 response_tx: TestResponseSender,
327 },
328 ClearText {
329 query: SelectorQuery,
330 response_tx: TestResponseSender,
331 },
332 Toggle {
333 query: SelectorQuery,
334 response_tx: TestResponseSender,
335 },
336 SelectOption {
337 query: SelectorQuery,
338 response_tx: TestResponseSender,
339 },
340 Wait {
342 ms: u64,
343 response_tx: TestResponseSender,
344 },
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349#[serde(tag = "kind", rename_all = "snake_case")]
350pub enum Selector {
351 SemanticIdentifier { identifier: String },
353 WidgetId { widget_id: String },
355 TestId { test_id: String },
357 AccessibilityIdentifier { identifier: String },
359 RoleLabel { role: String, label: String },
361 Label { label: String },
363}
364
365impl Selector {
366 pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
367 Self::SemanticIdentifier {
368 identifier: identifier.into(),
369 }
370 }
371
372 pub fn widget_id(widget_id: impl Into<String>) -> Self {
373 Self::WidgetId {
374 widget_id: widget_id.into(),
375 }
376 }
377
378 pub fn test_id(test_id: impl Into<String>) -> Self {
379 Self::TestId {
380 test_id: test_id.into(),
381 }
382 }
383
384 pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
385 Self::AccessibilityIdentifier {
386 identifier: identifier.into(),
387 }
388 }
389
390 pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
391 Self::RoleLabel {
392 role: role.into(),
393 label: label.into(),
394 }
395 }
396
397 pub fn label(label: impl Into<String>) -> Self {
398 Self::Label {
399 label: label.into(),
400 }
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct SelectorQuery {
407 pub selector: Selector,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub scope: Option<Box<SelectorQuery>>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub index: Option<usize>,
412 #[serde(default)]
413 pub include_hidden: bool,
414}
415
416impl SelectorQuery {
417 pub fn new(selector: Selector) -> Self {
418 Self {
419 selector,
420 scope: None,
421 index: None,
422 include_hidden: false,
423 }
424 }
425
426 pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
427 Self::new(Selector::semantic_identifier(identifier))
428 }
429
430 pub fn widget_id(widget_id: impl Into<String>) -> Self {
431 Self::new(Selector::widget_id(widget_id))
432 }
433
434 pub fn test_id(test_id: impl Into<String>) -> Self {
435 Self::new(Selector::test_id(test_id))
436 }
437
438 pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
439 Self::new(Selector::accessibility_identifier(identifier))
440 }
441
442 pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
443 Self::new(Selector::role_label(role, label))
444 }
445
446 pub fn label(label: impl Into<String>) -> Self {
447 Self::new(Selector::label(label))
448 }
449
450 pub fn scoped(mut self, scope: SelectorQuery) -> Self {
451 self.scope = Some(Box::new(scope));
452 self
453 }
454
455 pub fn index(mut self, index: usize) -> Self {
456 self.index = Some(index);
457 self
458 }
459
460 pub fn include_hidden(mut self) -> Self {
461 self.include_hidden = true;
462 self
463 }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
468pub struct Bounds {
469 pub x: f32,
470 pub y: f32,
471 pub width: f32,
472 pub height: f32,
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
477pub enum VisibilityState {
478 FullyVisible,
479 PartiallyVisible,
480 Hidden,
481}
482
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
485pub enum SelectorFailureKind {
486 NoMatch,
487 Ambiguous,
488 FoundButNotVisible,
489 Disabled,
490 ReadOnly,
491 UnsupportedAction,
492 Timeout,
493 StaleFrame,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct SelectorCandidate {
499 pub node: SemanticNode,
500 pub rejected_reason: Option<String>,
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct SelectorFailure {
506 pub kind: SelectorFailureKind,
507 pub selector: SelectorQuery,
508 pub candidates: Vec<SelectorCandidate>,
509 pub message: String,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct TextItem {
516 pub text: String,
517 pub x: f32,
518 pub y: f32,
519 pub width: f32,
520 pub height: f32,
521}
522
523#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct SemanticNode {
527 pub identifier: Option<String>,
528 pub widget_id: String,
529 pub stable_node_id: String,
530 pub parent: Option<String>,
531 pub children: Vec<String>,
532 pub role: String,
533 pub label: Option<String>,
534 pub value: Option<String>,
535 pub value_present: bool,
536 pub focusable: bool,
537 pub disabled: bool,
538 pub read_only: bool,
539 pub checked: Option<bool>,
540 pub actions: Vec<String>,
541 pub text_selection: Option<(usize, usize)>,
542 pub masked: bool,
543 pub scrollable_x: bool,
544 pub scrollable_y: bool,
545 pub logical_bounds: Bounds,
546 pub visible_bounds: Option<Bounds>,
547 pub visibility: VisibilityState,
548 pub x: f32,
549 pub y: f32,
550 pub width: f32,
551 pub height: f32,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize)]
556#[serde(tag = "status")]
557pub enum TestResponse {
558 Ok {},
559 Text {
560 items: Vec<TextItem>,
561 },
562 Tree {
563 nodes: Vec<SemanticNode>,
564 },
565 Screenshot {
566 png_base64: String,
567 width: u32,
569 height: u32,
571 },
572 MotionStatus {
573 finite: usize,
574 repeating: usize,
575 ripples: usize,
576 },
577 SelectorResolved {
578 node: SemanticNode,
579 },
580 SelectorError {
581 failure: SelectorFailure,
582 },
583 Error {
584 message: String,
585 },
586}
587
588pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
590
591#[cfg(not(target_arch = "wasm32"))]
609pub struct LiveTestClient {
610 base_url: String,
611}
612
613#[cfg(not(target_arch = "wasm32"))]
614impl LiveTestClient {
615 pub fn connect(port: u16) -> Self {
616 Self {
617 base_url: format!("http://127.0.0.1:{}", port),
618 }
619 }
620
621 pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
622 let start = std::time::Instant::now();
623 let timeout = std::time::Duration::from_millis(timeout_ms);
624 loop {
625 match ureq::get(&format!("{}/health", self.base_url)).call() {
626 Ok(_) => return Ok(()),
627 Err(_) => {
628 if start.elapsed() > timeout {
629 return Err(anyhow!("timed out waiting for test server"));
630 }
631 std::thread::sleep(std::time::Duration::from_millis(100));
632 }
633 }
634 }
635 }
636
637 fn send(&self, cmd: TestCommand) -> Result<TestResponse> {
638 let body = serde_json::to_string(&cmd)?;
639 let resp = ureq::post(&format!("{}/cmd", self.base_url))
640 .set("Content-Type", "application/json")
641 .send_string(&body)
642 .map_err(|e| anyhow!("request failed: {}", e))?;
643 let text = resp.into_string()?;
644 let response: TestResponse = serde_json::from_str(&text)?;
645 if let TestResponse::Error { message } = &response {
646 return Err(anyhow!("server error: {}", message));
647 }
648 if let TestResponse::SelectorError { failure } = &response {
649 return Err(anyhow!("selector error: {}", failure.message));
650 }
651 Ok(response)
652 }
653
654 pub fn tap(&self, x: f32, y: f32) -> Result<()> {
655 self.send(TestCommand::Tap { x, y })?;
656 Ok(())
657 }
658
659 pub fn tap_text(&self, text: &str) -> Result<()> {
660 self.pump()?;
662 self.send(TestCommand::TapText {
663 text: text.to_string(),
664 })?;
665 self.pump()?;
667 Ok(())
668 }
669
670 pub fn tap_text_without_pump(&self, text: &str) -> Result<()> {
671 self.send(TestCommand::TapText {
672 text: text.to_string(),
673 })?;
674 Ok(())
675 }
676
677 pub fn resolve_selector(&self, query: SelectorQuery) -> Result<SemanticNode> {
678 match self.send(TestCommand::ResolveSelector { query })? {
679 TestResponse::SelectorResolved { node } => Ok(node),
680 other => Err(anyhow!(
681 "unexpected response to ResolveSelector: {:?}",
682 other
683 )),
684 }
685 }
686
687 pub fn scroll_into_view(&self, query: SelectorQuery) -> Result<SemanticNode> {
688 let node = match self.send(TestCommand::ScrollIntoView { query })? {
689 TestResponse::SelectorResolved { node } => node,
690 other => {
691 return Err(anyhow!(
692 "unexpected response to ScrollIntoView: {:?}",
693 other
694 ))
695 }
696 };
697 self.pump()?;
698 Ok(node)
699 }
700
701 pub fn tap_selector(&self, query: SelectorQuery) -> Result<()> {
702 self.pump()?;
703 self.send(TestCommand::TapSelector { query })?;
704 self.pump()?;
705 Ok(())
706 }
707
708 pub fn tap_semantic_identifier(&self, identifier: &str) -> Result<()> {
709 self.tap_selector(SelectorQuery::semantic_identifier(identifier))
710 }
711
712 pub fn activate_selector(&self, query: SelectorQuery) -> Result<()> {
713 self.pump()?;
714 self.send(TestCommand::ActivateSelector { query })?;
715 self.pump()?;
716 Ok(())
717 }
718
719 pub fn focus_selector(&self, query: SelectorQuery) -> Result<()> {
720 self.pump()?;
721 self.send(TestCommand::FocusSelector { query })?;
722 self.pump()?;
723 Ok(())
724 }
725
726 pub fn hover_selector(&self, query: SelectorQuery) -> Result<()> {
727 self.pump()?;
728 self.send(TestCommand::HoverSelector { query })?;
729 self.pump()?;
730 Ok(())
731 }
732
733 pub fn right_click_selector(&self, query: SelectorQuery) -> Result<()> {
734 self.pump()?;
735 self.send(TestCommand::RightClickSelector { query })?;
736 self.pump()?;
737 Ok(())
738 }
739
740 pub fn fill_text_selector(&self, query: SelectorQuery, text: &str) -> Result<()> {
741 self.pump()?;
742 self.send(TestCommand::FillText {
743 query,
744 text: text.to_string(),
745 })?;
746 self.pump()?;
747 Ok(())
748 }
749
750 pub fn fill_text_semantic_identifier(&self, identifier: &str, text: &str) -> Result<()> {
751 self.fill_text_selector(SelectorQuery::semantic_identifier(identifier), text)
752 }
753
754 pub fn clear_text_selector(&self, query: SelectorQuery) -> Result<()> {
755 self.pump()?;
756 self.send(TestCommand::ClearText { query })?;
757 self.pump()?;
758 Ok(())
759 }
760
761 pub fn toggle_selector(&self, query: SelectorQuery) -> Result<()> {
762 self.pump()?;
763 self.send(TestCommand::Toggle { query })?;
764 self.pump()?;
765 Ok(())
766 }
767
768 pub fn select_option(&self, query: SelectorQuery) -> Result<()> {
769 self.pump()?;
770 self.send(TestCommand::SelectOption { query })?;
771 self.pump()?;
772 Ok(())
773 }
774
775 pub fn wait_for_selector(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
776 self.send(TestCommand::WaitForSelector { query, timeout_ms })?;
777 Ok(())
778 }
779
780 pub fn wait_for_visible(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
781 self.send(TestCommand::WaitForVisible { query, timeout_ms })?;
782 Ok(())
783 }
784
785 pub fn wait_for_enabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
786 self.send(TestCommand::WaitForEnabled { query, timeout_ms })?;
787 Ok(())
788 }
789
790 pub fn wait_for_disabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
791 self.send(TestCommand::WaitForDisabled { query, timeout_ms })?;
792 Ok(())
793 }
794
795 pub fn wait_for_value(&self, query: SelectorQuery, value: &str, timeout_ms: u64) -> Result<()> {
796 self.send(TestCommand::WaitForValue {
797 query,
798 value: value.to_string(),
799 timeout_ms,
800 })?;
801 Ok(())
802 }
803
804 pub fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
805 self.send(TestCommand::WaitForText {
806 text: text.to_string(),
807 timeout_ms,
808 })?;
809 Ok(())
810 }
811
812 pub fn wait_for_gone(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
813 self.send(TestCommand::WaitForGone { query, timeout_ms })?;
814 Ok(())
815 }
816
817 pub fn drag(
818 &self,
819 start_x: f32,
820 start_y: f32,
821 end_x: f32,
822 end_y: f32,
823 steps: u32,
824 ) -> Result<()> {
825 self.send(TestCommand::Drag {
826 start_x,
827 start_y,
828 end_x,
829 end_y,
830 steps,
831 })?;
832 self.pump()?;
833 Ok(())
834 }
835
836 pub fn external_file_hover(&self, x: f32, y: f32, paths: impl Into<Vec<String>>) -> Result<()> {
837 self.send(TestCommand::ExternalFileHover {
838 x,
839 y,
840 paths: paths.into(),
841 })?;
842 self.pump()?;
843 Ok(())
844 }
845
846 pub fn external_file_drop(&self, x: f32, y: f32, paths: impl Into<Vec<String>>) -> Result<()> {
847 self.send(TestCommand::ExternalFileDrop {
848 x,
849 y,
850 paths: paths.into(),
851 })?;
852 self.pump()?;
853 Ok(())
854 }
855
856 pub fn external_file_cancel(&self) -> Result<()> {
857 self.send(TestCommand::ExternalFileCancel {})?;
858 self.pump()?;
859 Ok(())
860 }
861
862 pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
863 self.send(TestCommand::Scroll { x, y, dx, dy })?;
864 Ok(())
865 }
866
867 pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
868 self.send(TestCommand::PressKey {
869 key: key.to_string(),
870 modifiers,
871 })?;
872 self.pump()?;
873 Ok(())
874 }
875
876 pub fn type_text(&self, text: &str) -> Result<()> {
877 self.send(TestCommand::TypeText {
878 text: text.to_string(),
879 })?;
880 Ok(())
881 }
882
883 pub fn ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> Result<()> {
884 self.send(TestCommand::ImePreedit {
885 text: text.to_string(),
886 cursor_start: cursor.map(|range| range.0),
887 cursor_end: cursor.map(|range| range.1),
888 })?;
889 self.pump()?;
890 Ok(())
891 }
892
893 pub fn ime_commit(&self, text: &str) -> Result<()> {
894 self.send(TestCommand::ImeCommit {
895 text: text.to_string(),
896 })?;
897 self.pump()?;
898 Ok(())
899 }
900
901 pub fn ime_cancel(&self) -> Result<()> {
902 self.send(TestCommand::ImeCancel {})?;
903 self.pump()?;
904 Ok(())
905 }
906
907 pub fn screenshot(&self, path: &str) -> Result<()> {
908 std::fs::write(path, self.capture_screenshot_png()?)?;
909 Ok(())
910 }
911
912 pub fn capture_screenshot_png(&self) -> Result<Vec<u8>> {
914 screenshot_bytes(self.send(TestCommand::CaptureScreenshot {})?)
915 }
916
917 pub fn compare_golden(
919 &self,
920 golden_path: impl AsRef<std::path::Path>,
921 diff_path: Option<impl AsRef<std::path::Path>>,
922 options: GoldenOptions,
923 ) -> Result<GoldenReport> {
924 let report = compare_png_to_golden(
925 &self.capture_screenshot_png()?,
926 golden_path,
927 diff_path,
928 options,
929 )?;
930 if !report.passed(options) {
931 return Err(anyhow!(
932 "golden comparison changed {:.4}% of pixels (allowed {:.4}%)",
933 report.changed_percent,
934 options.max_changed_percent
935 ));
936 }
937 Ok(report)
938 }
939
940 pub fn pause_animations(&self) -> Result<()> {
942 self.send(TestCommand::PauseAnimations {})?;
943 Ok(())
944 }
945
946 pub fn resume_animations(&self) -> Result<()> {
948 self.send(TestCommand::ResumeAnimations {})?;
949 Ok(())
950 }
951
952 pub fn advance_clock(&self, ms: u64) -> Result<()> {
954 self.send(TestCommand::AdvanceClock { ms })?;
955 self.pump()
956 }
957
958 pub fn capture_at(&self, ms: u64, path: &str) -> Result<()> {
960 std::fs::write(path, self.capture_at_png(ms)?)?;
961 Ok(())
962 }
963
964 pub fn capture_at_png(&self, ms: u64) -> Result<Vec<u8>> {
966 screenshot_bytes(self.send(TestCommand::CaptureAt { ms })?)
967 }
968
969 pub fn wait_for_idle(&self, timeout_ms: u64, ignore_repeating_motion: bool) -> Result<()> {
971 self.send(TestCommand::WaitForIdle {
972 timeout_ms,
973 ignore_repeating_motion,
974 })?;
975 Ok(())
976 }
977
978 pub fn get_text(&self) -> Result<Vec<TextItem>> {
979 match self.send(TestCommand::GetText {})? {
980 TestResponse::Text { items } => Ok(items),
981 other => Err(anyhow!("unexpected response: {:?}", other)),
982 }
983 }
984
985 pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
986 match self.send(TestCommand::GetTree {})? {
987 TestResponse::Tree { nodes } => Ok(nodes),
988 other => Err(anyhow!("unexpected response: {:?}", other)),
989 }
990 }
991
992 pub fn wait(&self, ms: u64) -> Result<()> {
993 self.send(TestCommand::Wait { ms })?;
994 Ok(())
995 }
996
997 pub fn pump(&self) -> Result<()> {
998 self.send(TestCommand::Pump {})?;
999 Ok(())
1000 }
1001
1002 pub fn quit(&self) -> Result<()> {
1003 let _ = self.send(TestCommand::Quit {});
1004 Ok(())
1005 }
1006
1007 pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
1011 self.send(TestCommand::SimulateMouseMove { x, y })?;
1012 Ok(())
1013 }
1014
1015 pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
1017 self.send(TestCommand::SimulateRightClick { x, y })?;
1018 Ok(())
1019 }
1020
1021 pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
1023 self.send(TestCommand::SimulateResize { width, height })?;
1024 Ok(())
1025 }
1026
1027 pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
1030 self.tap_text(text)?;
1031 self.wait(ms)?;
1032 Ok(())
1033 }
1034
1035 pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
1036 let items = self.get_text()?;
1037 let found = items.iter().any(|t| t.text.contains(needle));
1038 if !found {
1039 let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
1040 return Err(anyhow!(
1041 "expected '{}' to be visible, found: {:?}",
1042 needle,
1043 &all[..all.len().min(20)]
1044 ));
1045 }
1046 Ok(())
1047 }
1048
1049 pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
1050 let items = self.get_text()?;
1051 let found = items.iter().any(|t| t.text.contains(needle));
1052 if found {
1053 return Err(anyhow!("expected '{}' to NOT be visible", needle));
1054 }
1055 Ok(())
1056 }
1057}
1058
1059#[cfg(not(target_arch = "wasm32"))]
1060fn screenshot_bytes(response: TestResponse) -> Result<Vec<u8>> {
1061 match response {
1062 TestResponse::Screenshot {
1063 png_base64,
1064 width: _,
1065 height: _,
1066 } => base64::engine::general_purpose::STANDARD
1067 .decode(png_base64)
1068 .map_err(|error| anyhow!("invalid screenshot payload: {error}")),
1069 other => Err(anyhow!("expected screenshot response, received {other:?}")),
1070 }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::TestCommand;
1076
1077 #[test]
1078 fn deterministic_motion_commands_have_stable_wire_shapes() {
1079 assert_eq!(
1080 serde_json::to_value(TestCommand::PauseAnimations {}).expect("serialize pause"),
1081 serde_json::json!({ "cmd": "PauseAnimations" })
1082 );
1083 assert_eq!(
1084 serde_json::to_value(TestCommand::AdvanceClock { ms: 160 })
1085 .expect("serialize clock advance"),
1086 serde_json::json!({ "cmd": "AdvanceClock", "ms": 160 })
1087 );
1088 assert_eq!(
1089 serde_json::to_value(TestCommand::WaitForIdle {
1090 timeout_ms: 2_000,
1091 ignore_repeating_motion: true,
1092 })
1093 .expect("serialize idle wait"),
1094 serde_json::json!({
1095 "cmd": "WaitForIdle",
1096 "timeout_ms": 2_000,
1097 "ignore_repeating_motion": true
1098 })
1099 );
1100 }
1101}