1#[cfg(not(target_arch = "wasm32"))]
14use anyhow::{anyhow, Result};
15#[cfg(not(target_arch = "wasm32"))]
16use base64::Engine;
17use serde::{Deserialize, Serialize};
18
19#[cfg(not(target_arch = "wasm32"))]
20pub mod browser;
21#[cfg(not(target_arch = "wasm32"))]
22pub use browser::{
23 detect_chrome, run_browser_smoke, BrowserSmokeMode, BrowserSmokeReport, BrowserTestOptions,
24};
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(tag = "cmd")]
34pub enum TestCommand {
35 Tap {
36 x: f32,
37 y: f32,
38 },
39 Drag {
40 start_x: f32,
41 start_y: f32,
42 end_x: f32,
43 end_y: f32,
44 steps: u32,
45 },
46 TapText {
47 text: String,
48 },
49 ResolveSelector {
50 query: SelectorQuery,
51 },
52 TapSelector {
53 query: SelectorQuery,
54 },
55 ActivateSelector {
56 query: SelectorQuery,
57 },
58 FocusSelector {
59 query: SelectorQuery,
60 },
61 HoverSelector {
62 query: SelectorQuery,
63 },
64 RightClickSelector {
65 query: SelectorQuery,
66 },
67 ScrollIntoView {
68 query: SelectorQuery,
69 },
70 FillText {
71 query: SelectorQuery,
72 text: String,
73 },
74 ClearText {
75 query: SelectorQuery,
76 },
77 Toggle {
78 query: SelectorQuery,
79 },
80 SelectOption {
81 query: SelectorQuery,
82 },
83 WaitForSelector {
84 query: SelectorQuery,
85 timeout_ms: u64,
86 },
87 WaitForVisible {
88 query: SelectorQuery,
89 timeout_ms: u64,
90 },
91 WaitForEnabled {
92 query: SelectorQuery,
93 timeout_ms: u64,
94 },
95 WaitForDisabled {
96 query: SelectorQuery,
97 timeout_ms: u64,
98 },
99 WaitForValue {
100 query: SelectorQuery,
101 value: String,
102 timeout_ms: u64,
103 },
104 WaitForText {
105 text: String,
106 timeout_ms: u64,
107 },
108 WaitForGone {
109 query: SelectorQuery,
110 timeout_ms: u64,
111 },
112 Scroll {
113 x: f32,
114 y: f32,
115 dx: f32,
116 dy: f32,
117 },
118 TypeText {
119 text: String,
120 },
121 ImePreedit {
122 text: String,
123 cursor_start: Option<usize>,
124 cursor_end: Option<usize>,
125 },
126 ImeCommit {
127 text: String,
128 },
129 ImeCancel {},
130 PressKey {
131 key: String,
132 modifiers: u8,
133 },
134 Screenshot {
135 path: String,
136 },
137 CaptureScreenshot {},
138 GetText {},
139 GetTree {},
140 Wait {
141 ms: u64,
142 },
143 Pump {},
144 Quit {},
145 SimulateMouseMove {
147 x: f32,
148 y: f32,
149 },
150 SimulateRightClick {
151 x: f32,
152 y: f32,
153 },
154 SimulateResize {
155 width: u32,
157 height: u32,
159 },
160}
161
162#[derive(Debug, Clone)]
171pub enum TestEvent {
172 MouseMove {
174 x: f32,
175 y: f32,
176 },
177 MouseDown {
178 x: f32,
179 y: f32,
180 button: u8,
181 }, MouseUp {
183 x: f32,
184 y: f32,
185 button: u8,
186 },
187 KeyDown {
188 key_code: String,
189 modifiers: u8,
190 },
191 KeyUp {
192 key_code: String,
193 modifiers: u8,
194 },
195 TextInput {
196 text: String,
197 },
198 ImePreedit {
199 text: String,
200 cursor: Option<(usize, usize)>,
201 },
202 ImeCommit {
203 text: String,
204 },
205 ImeCancel,
206 Scroll {
207 x: f32,
208 y: f32,
209 dx: f32,
210 dy: f32,
211 },
212 Resize {
213 width: u32,
214 height: u32,
215 },
216 Screenshot {
218 path: String,
219 response_tx: TestResponseSender,
220 },
221 CaptureScreenshot {
222 response_tx: TestResponseSender,
223 },
224 GetText {
225 response_tx: TestResponseSender,
226 },
227 GetTree {
228 response_tx: TestResponseSender,
229 },
230 Pump {
231 response_tx: TestResponseSender,
232 },
233 Wake,
234 Quit,
235 TapText {
238 text: String,
239 response_tx: TestResponseSender,
240 },
241 ResolveSelector {
242 query: SelectorQuery,
243 response_tx: TestResponseSender,
244 },
245 TapSelector {
246 query: SelectorQuery,
247 response_tx: TestResponseSender,
248 },
249 ActivateSelector {
250 query: SelectorQuery,
251 response_tx: TestResponseSender,
252 },
253 FocusSelector {
254 query: SelectorQuery,
255 response_tx: TestResponseSender,
256 },
257 HoverSelector {
258 query: SelectorQuery,
259 response_tx: TestResponseSender,
260 },
261 RightClickSelector {
262 query: SelectorQuery,
263 response_tx: TestResponseSender,
264 },
265 ScrollIntoView {
266 query: SelectorQuery,
267 response_tx: TestResponseSender,
268 },
269 FillText {
270 query: SelectorQuery,
271 text: String,
272 response_tx: TestResponseSender,
273 },
274 ClearText {
275 query: SelectorQuery,
276 response_tx: TestResponseSender,
277 },
278 Toggle {
279 query: SelectorQuery,
280 response_tx: TestResponseSender,
281 },
282 SelectOption {
283 query: SelectorQuery,
284 response_tx: TestResponseSender,
285 },
286 Wait {
288 ms: u64,
289 response_tx: TestResponseSender,
290 },
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
295#[serde(tag = "kind", rename_all = "snake_case")]
296pub enum Selector {
297 SemanticIdentifier { identifier: String },
299 WidgetId { widget_id: String },
301 TestId { test_id: String },
303 AccessibilityIdentifier { identifier: String },
305 RoleLabel { role: String, label: String },
307 Label { label: String },
309}
310
311impl Selector {
312 pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
313 Self::SemanticIdentifier {
314 identifier: identifier.into(),
315 }
316 }
317
318 pub fn widget_id(widget_id: impl Into<String>) -> Self {
319 Self::WidgetId {
320 widget_id: widget_id.into(),
321 }
322 }
323
324 pub fn test_id(test_id: impl Into<String>) -> Self {
325 Self::TestId {
326 test_id: test_id.into(),
327 }
328 }
329
330 pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
331 Self::AccessibilityIdentifier {
332 identifier: identifier.into(),
333 }
334 }
335
336 pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
337 Self::RoleLabel {
338 role: role.into(),
339 label: label.into(),
340 }
341 }
342
343 pub fn label(label: impl Into<String>) -> Self {
344 Self::Label {
345 label: label.into(),
346 }
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352pub struct SelectorQuery {
353 pub selector: Selector,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub scope: Option<Box<SelectorQuery>>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub index: Option<usize>,
358 #[serde(default)]
359 pub include_hidden: bool,
360}
361
362impl SelectorQuery {
363 pub fn new(selector: Selector) -> Self {
364 Self {
365 selector,
366 scope: None,
367 index: None,
368 include_hidden: false,
369 }
370 }
371
372 pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
373 Self::new(Selector::semantic_identifier(identifier))
374 }
375
376 pub fn widget_id(widget_id: impl Into<String>) -> Self {
377 Self::new(Selector::widget_id(widget_id))
378 }
379
380 pub fn test_id(test_id: impl Into<String>) -> Self {
381 Self::new(Selector::test_id(test_id))
382 }
383
384 pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
385 Self::new(Selector::accessibility_identifier(identifier))
386 }
387
388 pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
389 Self::new(Selector::role_label(role, label))
390 }
391
392 pub fn label(label: impl Into<String>) -> Self {
393 Self::new(Selector::label(label))
394 }
395
396 pub fn scoped(mut self, scope: SelectorQuery) -> Self {
397 self.scope = Some(Box::new(scope));
398 self
399 }
400
401 pub fn index(mut self, index: usize) -> Self {
402 self.index = Some(index);
403 self
404 }
405
406 pub fn include_hidden(mut self) -> Self {
407 self.include_hidden = true;
408 self
409 }
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
414pub struct Bounds {
415 pub x: f32,
416 pub y: f32,
417 pub width: f32,
418 pub height: f32,
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423pub enum VisibilityState {
424 FullyVisible,
425 PartiallyVisible,
426 Hidden,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
431pub enum SelectorFailureKind {
432 NoMatch,
433 Ambiguous,
434 FoundButNotVisible,
435 Disabled,
436 ReadOnly,
437 UnsupportedAction,
438 Timeout,
439 StaleFrame,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct SelectorCandidate {
445 pub node: SemanticNode,
446 pub rejected_reason: Option<String>,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct SelectorFailure {
452 pub kind: SelectorFailureKind,
453 pub selector: SelectorQuery,
454 pub candidates: Vec<SelectorCandidate>,
455 pub message: String,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct TextItem {
462 pub text: String,
463 pub x: f32,
464 pub y: f32,
465 pub width: f32,
466 pub height: f32,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct SemanticNode {
473 pub identifier: Option<String>,
474 pub widget_id: String,
475 pub stable_node_id: String,
476 pub parent: Option<String>,
477 pub children: Vec<String>,
478 pub role: String,
479 pub label: Option<String>,
480 pub value: Option<String>,
481 pub value_present: bool,
482 pub focusable: bool,
483 pub disabled: bool,
484 pub read_only: bool,
485 pub checked: Option<bool>,
486 pub actions: Vec<String>,
487 pub text_selection: Option<(usize, usize)>,
488 pub masked: bool,
489 pub scrollable_x: bool,
490 pub scrollable_y: bool,
491 pub logical_bounds: Bounds,
492 pub visible_bounds: Option<Bounds>,
493 pub visibility: VisibilityState,
494 pub x: f32,
495 pub y: f32,
496 pub width: f32,
497 pub height: f32,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize)]
502#[serde(tag = "status")]
503pub enum TestResponse {
504 Ok {},
505 Text {
506 items: Vec<TextItem>,
507 },
508 Tree {
509 nodes: Vec<SemanticNode>,
510 },
511 Screenshot {
512 png_base64: String,
513 width: u32,
515 height: u32,
517 },
518 SelectorResolved {
519 node: SemanticNode,
520 },
521 SelectorError {
522 failure: SelectorFailure,
523 },
524 Error {
525 message: String,
526 },
527}
528
529pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
531
532#[cfg(not(target_arch = "wasm32"))]
550pub struct LiveTestClient {
551 base_url: String,
552}
553
554#[cfg(not(target_arch = "wasm32"))]
555impl LiveTestClient {
556 pub fn connect(port: u16) -> Self {
557 Self {
558 base_url: format!("http://127.0.0.1:{}", port),
559 }
560 }
561
562 pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
563 let start = std::time::Instant::now();
564 let timeout = std::time::Duration::from_millis(timeout_ms);
565 loop {
566 match ureq::get(&format!("{}/health", self.base_url)).call() {
567 Ok(_) => return Ok(()),
568 Err(_) => {
569 if start.elapsed() > timeout {
570 return Err(anyhow!("timed out waiting for test server"));
571 }
572 std::thread::sleep(std::time::Duration::from_millis(100));
573 }
574 }
575 }
576 }
577
578 fn send(&self, cmd: TestCommand) -> Result<TestResponse> {
579 let body = serde_json::to_string(&cmd)?;
580 let resp = ureq::post(&format!("{}/cmd", self.base_url))
581 .set("Content-Type", "application/json")
582 .send_string(&body)
583 .map_err(|e| anyhow!("request failed: {}", e))?;
584 let text = resp.into_string()?;
585 let response: TestResponse = serde_json::from_str(&text)?;
586 if let TestResponse::Error { message } = &response {
587 return Err(anyhow!("server error: {}", message));
588 }
589 if let TestResponse::SelectorError { failure } = &response {
590 return Err(anyhow!("selector error: {}", failure.message));
591 }
592 Ok(response)
593 }
594
595 pub fn tap(&self, x: f32, y: f32) -> Result<()> {
596 self.send(TestCommand::Tap { x, y })?;
597 Ok(())
598 }
599
600 pub fn tap_text(&self, text: &str) -> Result<()> {
601 self.pump()?;
603 self.send(TestCommand::TapText {
604 text: text.to_string(),
605 })?;
606 self.pump()?;
608 Ok(())
609 }
610
611 pub fn tap_text_without_pump(&self, text: &str) -> Result<()> {
612 self.send(TestCommand::TapText {
613 text: text.to_string(),
614 })?;
615 Ok(())
616 }
617
618 pub fn resolve_selector(&self, query: SelectorQuery) -> Result<SemanticNode> {
619 match self.send(TestCommand::ResolveSelector { query })? {
620 TestResponse::SelectorResolved { node } => Ok(node),
621 other => Err(anyhow!(
622 "unexpected response to ResolveSelector: {:?}",
623 other
624 )),
625 }
626 }
627
628 pub fn scroll_into_view(&self, query: SelectorQuery) -> Result<SemanticNode> {
629 let node = match self.send(TestCommand::ScrollIntoView { query })? {
630 TestResponse::SelectorResolved { node } => node,
631 other => {
632 return Err(anyhow!(
633 "unexpected response to ScrollIntoView: {:?}",
634 other
635 ))
636 }
637 };
638 self.pump()?;
639 Ok(node)
640 }
641
642 pub fn tap_selector(&self, query: SelectorQuery) -> Result<()> {
643 self.pump()?;
644 self.send(TestCommand::TapSelector { query })?;
645 self.pump()?;
646 Ok(())
647 }
648
649 pub fn tap_semantic_identifier(&self, identifier: &str) -> Result<()> {
650 self.tap_selector(SelectorQuery::semantic_identifier(identifier))
651 }
652
653 pub fn activate_selector(&self, query: SelectorQuery) -> Result<()> {
654 self.pump()?;
655 self.send(TestCommand::ActivateSelector { query })?;
656 self.pump()?;
657 Ok(())
658 }
659
660 pub fn focus_selector(&self, query: SelectorQuery) -> Result<()> {
661 self.pump()?;
662 self.send(TestCommand::FocusSelector { query })?;
663 self.pump()?;
664 Ok(())
665 }
666
667 pub fn hover_selector(&self, query: SelectorQuery) -> Result<()> {
668 self.pump()?;
669 self.send(TestCommand::HoverSelector { query })?;
670 self.pump()?;
671 Ok(())
672 }
673
674 pub fn right_click_selector(&self, query: SelectorQuery) -> Result<()> {
675 self.pump()?;
676 self.send(TestCommand::RightClickSelector { query })?;
677 self.pump()?;
678 Ok(())
679 }
680
681 pub fn fill_text_selector(&self, query: SelectorQuery, text: &str) -> Result<()> {
682 self.pump()?;
683 self.send(TestCommand::FillText {
684 query,
685 text: text.to_string(),
686 })?;
687 self.pump()?;
688 Ok(())
689 }
690
691 pub fn fill_text_semantic_identifier(&self, identifier: &str, text: &str) -> Result<()> {
692 self.fill_text_selector(SelectorQuery::semantic_identifier(identifier), text)
693 }
694
695 pub fn clear_text_selector(&self, query: SelectorQuery) -> Result<()> {
696 self.pump()?;
697 self.send(TestCommand::ClearText { query })?;
698 self.pump()?;
699 Ok(())
700 }
701
702 pub fn toggle_selector(&self, query: SelectorQuery) -> Result<()> {
703 self.pump()?;
704 self.send(TestCommand::Toggle { query })?;
705 self.pump()?;
706 Ok(())
707 }
708
709 pub fn select_option(&self, query: SelectorQuery) -> Result<()> {
710 self.pump()?;
711 self.send(TestCommand::SelectOption { query })?;
712 self.pump()?;
713 Ok(())
714 }
715
716 pub fn wait_for_selector(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
717 self.send(TestCommand::WaitForSelector { query, timeout_ms })?;
718 Ok(())
719 }
720
721 pub fn wait_for_visible(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
722 self.send(TestCommand::WaitForVisible { query, timeout_ms })?;
723 Ok(())
724 }
725
726 pub fn wait_for_enabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
727 self.send(TestCommand::WaitForEnabled { query, timeout_ms })?;
728 Ok(())
729 }
730
731 pub fn wait_for_disabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
732 self.send(TestCommand::WaitForDisabled { query, timeout_ms })?;
733 Ok(())
734 }
735
736 pub fn wait_for_value(&self, query: SelectorQuery, value: &str, timeout_ms: u64) -> Result<()> {
737 self.send(TestCommand::WaitForValue {
738 query,
739 value: value.to_string(),
740 timeout_ms,
741 })?;
742 Ok(())
743 }
744
745 pub fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
746 self.send(TestCommand::WaitForText {
747 text: text.to_string(),
748 timeout_ms,
749 })?;
750 Ok(())
751 }
752
753 pub fn wait_for_gone(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
754 self.send(TestCommand::WaitForGone { query, timeout_ms })?;
755 Ok(())
756 }
757
758 pub fn drag(
759 &self,
760 start_x: f32,
761 start_y: f32,
762 end_x: f32,
763 end_y: f32,
764 steps: u32,
765 ) -> Result<()> {
766 self.send(TestCommand::Drag {
767 start_x,
768 start_y,
769 end_x,
770 end_y,
771 steps,
772 })?;
773 self.pump()?;
774 Ok(())
775 }
776
777 pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
778 self.send(TestCommand::Scroll { x, y, dx, dy })?;
779 Ok(())
780 }
781
782 pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
783 self.send(TestCommand::PressKey {
784 key: key.to_string(),
785 modifiers,
786 })?;
787 self.pump()?;
788 Ok(())
789 }
790
791 pub fn type_text(&self, text: &str) -> Result<()> {
792 self.send(TestCommand::TypeText {
793 text: text.to_string(),
794 })?;
795 Ok(())
796 }
797
798 pub fn ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> Result<()> {
799 self.send(TestCommand::ImePreedit {
800 text: text.to_string(),
801 cursor_start: cursor.map(|range| range.0),
802 cursor_end: cursor.map(|range| range.1),
803 })?;
804 self.pump()?;
805 Ok(())
806 }
807
808 pub fn ime_commit(&self, text: &str) -> Result<()> {
809 self.send(TestCommand::ImeCommit {
810 text: text.to_string(),
811 })?;
812 self.pump()?;
813 Ok(())
814 }
815
816 pub fn ime_cancel(&self) -> Result<()> {
817 self.send(TestCommand::ImeCancel {})?;
818 self.pump()?;
819 Ok(())
820 }
821
822 pub fn screenshot(&self, path: &str) -> Result<()> {
823 match self.send(TestCommand::CaptureScreenshot {})? {
824 TestResponse::Screenshot {
825 png_base64,
826 width: _,
827 height: _,
828 } => {
829 let bytes = base64::engine::general_purpose::STANDARD
830 .decode(png_base64)
831 .map_err(|e| anyhow!("invalid screenshot payload: {}", e))?;
832 std::fs::write(path, bytes)?;
833 Ok(())
834 }
835 other => Err(anyhow!(
836 "unexpected response to CaptureScreenshot: {:?}",
837 other
838 )),
839 }
840 }
841
842 pub fn get_text(&self) -> Result<Vec<TextItem>> {
843 match self.send(TestCommand::GetText {})? {
844 TestResponse::Text { items } => Ok(items),
845 other => Err(anyhow!("unexpected response: {:?}", other)),
846 }
847 }
848
849 pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
850 match self.send(TestCommand::GetTree {})? {
851 TestResponse::Tree { nodes } => Ok(nodes),
852 other => Err(anyhow!("unexpected response: {:?}", other)),
853 }
854 }
855
856 pub fn wait(&self, ms: u64) -> Result<()> {
857 self.send(TestCommand::Wait { ms })?;
858 Ok(())
859 }
860
861 pub fn pump(&self) -> Result<()> {
862 self.send(TestCommand::Pump {})?;
863 Ok(())
864 }
865
866 pub fn quit(&self) -> Result<()> {
867 let _ = self.send(TestCommand::Quit {});
868 Ok(())
869 }
870
871 pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
875 self.send(TestCommand::SimulateMouseMove { x, y })?;
876 Ok(())
877 }
878
879 pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
881 self.send(TestCommand::SimulateRightClick { x, y })?;
882 Ok(())
883 }
884
885 pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
887 self.send(TestCommand::SimulateResize { width, height })?;
888 Ok(())
889 }
890
891 pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
894 self.tap_text(text)?;
895 self.wait(ms)?;
896 Ok(())
897 }
898
899 pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
900 let items = self.get_text()?;
901 let found = items.iter().any(|t| t.text.contains(needle));
902 if !found {
903 let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
904 return Err(anyhow!(
905 "expected '{}' to be visible, found: {:?}",
906 needle,
907 &all[..all.len().min(20)]
908 ));
909 }
910 Ok(())
911 }
912
913 pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
914 let items = self.get_text()?;
915 let found = items.iter().any(|t| t.text.contains(needle));
916 if found {
917 return Err(anyhow!("expected '{}' to NOT be visible", needle));
918 }
919 Ok(())
920 }
921}