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 ExternalFileHover {
119 x: f32,
120 y: f32,
121 paths: Vec<String>,
122 },
123 ExternalFileDrop {
124 x: f32,
125 y: f32,
126 paths: Vec<String>,
127 },
128 ExternalFileCancel {},
129 TypeText {
130 text: String,
131 },
132 ImePreedit {
133 text: String,
134 cursor_start: Option<usize>,
135 cursor_end: Option<usize>,
136 },
137 ImeCommit {
138 text: String,
139 },
140 ImeCancel {},
141 PressKey {
142 key: String,
143 modifiers: u8,
144 },
145 Screenshot {
146 path: String,
147 },
148 CaptureScreenshot {},
149 GetText {},
150 GetTree {},
151 Wait {
152 ms: u64,
153 },
154 Pump {},
155 Quit {},
156 SimulateMouseMove {
158 x: f32,
159 y: f32,
160 },
161 SimulateRightClick {
162 x: f32,
163 y: f32,
164 },
165 SimulateResize {
166 width: u32,
168 height: u32,
170 },
171}
172
173#[derive(Debug, Clone)]
182pub enum TestEvent {
183 MouseMove {
185 x: f32,
186 y: f32,
187 },
188 MouseDown {
189 x: f32,
190 y: f32,
191 button: u8,
192 }, MouseUp {
194 x: f32,
195 y: f32,
196 button: u8,
197 },
198 KeyDown {
199 key_code: String,
200 modifiers: u8,
201 },
202 KeyUp {
203 key_code: String,
204 modifiers: u8,
205 },
206 TextInput {
207 text: String,
208 },
209 ImePreedit {
210 text: String,
211 cursor: Option<(usize, usize)>,
212 },
213 ImeCommit {
214 text: String,
215 },
216 ImeCancel,
217 Scroll {
218 x: f32,
219 y: f32,
220 dx: f32,
221 dy: f32,
222 },
223 ExternalFileHover {
224 x: f32,
225 y: f32,
226 paths: Vec<String>,
227 },
228 ExternalFileDrop {
229 x: f32,
230 y: f32,
231 paths: Vec<String>,
232 },
233 ExternalFileCancel,
234 Resize {
235 width: u32,
236 height: u32,
237 },
238 Screenshot {
240 path: String,
241 response_tx: TestResponseSender,
242 },
243 CaptureScreenshot {
244 response_tx: TestResponseSender,
245 },
246 GetText {
247 response_tx: TestResponseSender,
248 },
249 GetTree {
250 response_tx: TestResponseSender,
251 },
252 Pump {
253 response_tx: TestResponseSender,
254 },
255 Wake,
256 Quit,
257 TapText {
260 text: String,
261 response_tx: TestResponseSender,
262 },
263 ResolveSelector {
264 query: SelectorQuery,
265 response_tx: TestResponseSender,
266 },
267 TapSelector {
268 query: SelectorQuery,
269 response_tx: TestResponseSender,
270 },
271 ActivateSelector {
272 query: SelectorQuery,
273 response_tx: TestResponseSender,
274 },
275 FocusSelector {
276 query: SelectorQuery,
277 response_tx: TestResponseSender,
278 },
279 HoverSelector {
280 query: SelectorQuery,
281 response_tx: TestResponseSender,
282 },
283 RightClickSelector {
284 query: SelectorQuery,
285 response_tx: TestResponseSender,
286 },
287 ScrollIntoView {
288 query: SelectorQuery,
289 response_tx: TestResponseSender,
290 },
291 FillText {
292 query: SelectorQuery,
293 text: String,
294 response_tx: TestResponseSender,
295 },
296 ClearText {
297 query: SelectorQuery,
298 response_tx: TestResponseSender,
299 },
300 Toggle {
301 query: SelectorQuery,
302 response_tx: TestResponseSender,
303 },
304 SelectOption {
305 query: SelectorQuery,
306 response_tx: TestResponseSender,
307 },
308 Wait {
310 ms: u64,
311 response_tx: TestResponseSender,
312 },
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317#[serde(tag = "kind", rename_all = "snake_case")]
318pub enum Selector {
319 SemanticIdentifier { identifier: String },
321 WidgetId { widget_id: String },
323 TestId { test_id: String },
325 AccessibilityIdentifier { identifier: String },
327 RoleLabel { role: String, label: String },
329 Label { label: String },
331}
332
333impl Selector {
334 pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
335 Self::SemanticIdentifier {
336 identifier: identifier.into(),
337 }
338 }
339
340 pub fn widget_id(widget_id: impl Into<String>) -> Self {
341 Self::WidgetId {
342 widget_id: widget_id.into(),
343 }
344 }
345
346 pub fn test_id(test_id: impl Into<String>) -> Self {
347 Self::TestId {
348 test_id: test_id.into(),
349 }
350 }
351
352 pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
353 Self::AccessibilityIdentifier {
354 identifier: identifier.into(),
355 }
356 }
357
358 pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
359 Self::RoleLabel {
360 role: role.into(),
361 label: label.into(),
362 }
363 }
364
365 pub fn label(label: impl Into<String>) -> Self {
366 Self::Label {
367 label: label.into(),
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374pub struct SelectorQuery {
375 pub selector: Selector,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub scope: Option<Box<SelectorQuery>>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub index: Option<usize>,
380 #[serde(default)]
381 pub include_hidden: bool,
382}
383
384impl SelectorQuery {
385 pub fn new(selector: Selector) -> Self {
386 Self {
387 selector,
388 scope: None,
389 index: None,
390 include_hidden: false,
391 }
392 }
393
394 pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
395 Self::new(Selector::semantic_identifier(identifier))
396 }
397
398 pub fn widget_id(widget_id: impl Into<String>) -> Self {
399 Self::new(Selector::widget_id(widget_id))
400 }
401
402 pub fn test_id(test_id: impl Into<String>) -> Self {
403 Self::new(Selector::test_id(test_id))
404 }
405
406 pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
407 Self::new(Selector::accessibility_identifier(identifier))
408 }
409
410 pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
411 Self::new(Selector::role_label(role, label))
412 }
413
414 pub fn label(label: impl Into<String>) -> Self {
415 Self::new(Selector::label(label))
416 }
417
418 pub fn scoped(mut self, scope: SelectorQuery) -> Self {
419 self.scope = Some(Box::new(scope));
420 self
421 }
422
423 pub fn index(mut self, index: usize) -> Self {
424 self.index = Some(index);
425 self
426 }
427
428 pub fn include_hidden(mut self) -> Self {
429 self.include_hidden = true;
430 self
431 }
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
436pub struct Bounds {
437 pub x: f32,
438 pub y: f32,
439 pub width: f32,
440 pub height: f32,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
445pub enum VisibilityState {
446 FullyVisible,
447 PartiallyVisible,
448 Hidden,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453pub enum SelectorFailureKind {
454 NoMatch,
455 Ambiguous,
456 FoundButNotVisible,
457 Disabled,
458 ReadOnly,
459 UnsupportedAction,
460 Timeout,
461 StaleFrame,
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct SelectorCandidate {
467 pub node: SemanticNode,
468 pub rejected_reason: Option<String>,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct SelectorFailure {
474 pub kind: SelectorFailureKind,
475 pub selector: SelectorQuery,
476 pub candidates: Vec<SelectorCandidate>,
477 pub message: String,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct TextItem {
484 pub text: String,
485 pub x: f32,
486 pub y: f32,
487 pub width: f32,
488 pub height: f32,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct SemanticNode {
495 pub identifier: Option<String>,
496 pub widget_id: String,
497 pub stable_node_id: String,
498 pub parent: Option<String>,
499 pub children: Vec<String>,
500 pub role: String,
501 pub label: Option<String>,
502 pub value: Option<String>,
503 pub value_present: bool,
504 pub focusable: bool,
505 pub disabled: bool,
506 pub read_only: bool,
507 pub checked: Option<bool>,
508 pub actions: Vec<String>,
509 pub text_selection: Option<(usize, usize)>,
510 pub masked: bool,
511 pub scrollable_x: bool,
512 pub scrollable_y: bool,
513 pub logical_bounds: Bounds,
514 pub visible_bounds: Option<Bounds>,
515 pub visibility: VisibilityState,
516 pub x: f32,
517 pub y: f32,
518 pub width: f32,
519 pub height: f32,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
524#[serde(tag = "status")]
525pub enum TestResponse {
526 Ok {},
527 Text {
528 items: Vec<TextItem>,
529 },
530 Tree {
531 nodes: Vec<SemanticNode>,
532 },
533 Screenshot {
534 png_base64: String,
535 width: u32,
537 height: u32,
539 },
540 SelectorResolved {
541 node: SemanticNode,
542 },
543 SelectorError {
544 failure: SelectorFailure,
545 },
546 Error {
547 message: String,
548 },
549}
550
551pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
553
554#[cfg(not(target_arch = "wasm32"))]
572pub struct LiveTestClient {
573 base_url: String,
574}
575
576#[cfg(not(target_arch = "wasm32"))]
577impl LiveTestClient {
578 pub fn connect(port: u16) -> Self {
579 Self {
580 base_url: format!("http://127.0.0.1:{}", port),
581 }
582 }
583
584 pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
585 let start = std::time::Instant::now();
586 let timeout = std::time::Duration::from_millis(timeout_ms);
587 loop {
588 match ureq::get(&format!("{}/health", self.base_url)).call() {
589 Ok(_) => return Ok(()),
590 Err(_) => {
591 if start.elapsed() > timeout {
592 return Err(anyhow!("timed out waiting for test server"));
593 }
594 std::thread::sleep(std::time::Duration::from_millis(100));
595 }
596 }
597 }
598 }
599
600 fn send(&self, cmd: TestCommand) -> Result<TestResponse> {
601 let body = serde_json::to_string(&cmd)?;
602 let resp = ureq::post(&format!("{}/cmd", self.base_url))
603 .set("Content-Type", "application/json")
604 .send_string(&body)
605 .map_err(|e| anyhow!("request failed: {}", e))?;
606 let text = resp.into_string()?;
607 let response: TestResponse = serde_json::from_str(&text)?;
608 if let TestResponse::Error { message } = &response {
609 return Err(anyhow!("server error: {}", message));
610 }
611 if let TestResponse::SelectorError { failure } = &response {
612 return Err(anyhow!("selector error: {}", failure.message));
613 }
614 Ok(response)
615 }
616
617 pub fn tap(&self, x: f32, y: f32) -> Result<()> {
618 self.send(TestCommand::Tap { x, y })?;
619 Ok(())
620 }
621
622 pub fn tap_text(&self, text: &str) -> Result<()> {
623 self.pump()?;
625 self.send(TestCommand::TapText {
626 text: text.to_string(),
627 })?;
628 self.pump()?;
630 Ok(())
631 }
632
633 pub fn tap_text_without_pump(&self, text: &str) -> Result<()> {
634 self.send(TestCommand::TapText {
635 text: text.to_string(),
636 })?;
637 Ok(())
638 }
639
640 pub fn resolve_selector(&self, query: SelectorQuery) -> Result<SemanticNode> {
641 match self.send(TestCommand::ResolveSelector { query })? {
642 TestResponse::SelectorResolved { node } => Ok(node),
643 other => Err(anyhow!(
644 "unexpected response to ResolveSelector: {:?}",
645 other
646 )),
647 }
648 }
649
650 pub fn scroll_into_view(&self, query: SelectorQuery) -> Result<SemanticNode> {
651 let node = match self.send(TestCommand::ScrollIntoView { query })? {
652 TestResponse::SelectorResolved { node } => node,
653 other => {
654 return Err(anyhow!(
655 "unexpected response to ScrollIntoView: {:?}",
656 other
657 ))
658 }
659 };
660 self.pump()?;
661 Ok(node)
662 }
663
664 pub fn tap_selector(&self, query: SelectorQuery) -> Result<()> {
665 self.pump()?;
666 self.send(TestCommand::TapSelector { query })?;
667 self.pump()?;
668 Ok(())
669 }
670
671 pub fn tap_semantic_identifier(&self, identifier: &str) -> Result<()> {
672 self.tap_selector(SelectorQuery::semantic_identifier(identifier))
673 }
674
675 pub fn activate_selector(&self, query: SelectorQuery) -> Result<()> {
676 self.pump()?;
677 self.send(TestCommand::ActivateSelector { query })?;
678 self.pump()?;
679 Ok(())
680 }
681
682 pub fn focus_selector(&self, query: SelectorQuery) -> Result<()> {
683 self.pump()?;
684 self.send(TestCommand::FocusSelector { query })?;
685 self.pump()?;
686 Ok(())
687 }
688
689 pub fn hover_selector(&self, query: SelectorQuery) -> Result<()> {
690 self.pump()?;
691 self.send(TestCommand::HoverSelector { query })?;
692 self.pump()?;
693 Ok(())
694 }
695
696 pub fn right_click_selector(&self, query: SelectorQuery) -> Result<()> {
697 self.pump()?;
698 self.send(TestCommand::RightClickSelector { query })?;
699 self.pump()?;
700 Ok(())
701 }
702
703 pub fn fill_text_selector(&self, query: SelectorQuery, text: &str) -> Result<()> {
704 self.pump()?;
705 self.send(TestCommand::FillText {
706 query,
707 text: text.to_string(),
708 })?;
709 self.pump()?;
710 Ok(())
711 }
712
713 pub fn fill_text_semantic_identifier(&self, identifier: &str, text: &str) -> Result<()> {
714 self.fill_text_selector(SelectorQuery::semantic_identifier(identifier), text)
715 }
716
717 pub fn clear_text_selector(&self, query: SelectorQuery) -> Result<()> {
718 self.pump()?;
719 self.send(TestCommand::ClearText { query })?;
720 self.pump()?;
721 Ok(())
722 }
723
724 pub fn toggle_selector(&self, query: SelectorQuery) -> Result<()> {
725 self.pump()?;
726 self.send(TestCommand::Toggle { query })?;
727 self.pump()?;
728 Ok(())
729 }
730
731 pub fn select_option(&self, query: SelectorQuery) -> Result<()> {
732 self.pump()?;
733 self.send(TestCommand::SelectOption { query })?;
734 self.pump()?;
735 Ok(())
736 }
737
738 pub fn wait_for_selector(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
739 self.send(TestCommand::WaitForSelector { query, timeout_ms })?;
740 Ok(())
741 }
742
743 pub fn wait_for_visible(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
744 self.send(TestCommand::WaitForVisible { query, timeout_ms })?;
745 Ok(())
746 }
747
748 pub fn wait_for_enabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
749 self.send(TestCommand::WaitForEnabled { query, timeout_ms })?;
750 Ok(())
751 }
752
753 pub fn wait_for_disabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
754 self.send(TestCommand::WaitForDisabled { query, timeout_ms })?;
755 Ok(())
756 }
757
758 pub fn wait_for_value(&self, query: SelectorQuery, value: &str, timeout_ms: u64) -> Result<()> {
759 self.send(TestCommand::WaitForValue {
760 query,
761 value: value.to_string(),
762 timeout_ms,
763 })?;
764 Ok(())
765 }
766
767 pub fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
768 self.send(TestCommand::WaitForText {
769 text: text.to_string(),
770 timeout_ms,
771 })?;
772 Ok(())
773 }
774
775 pub fn wait_for_gone(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
776 self.send(TestCommand::WaitForGone { query, timeout_ms })?;
777 Ok(())
778 }
779
780 pub fn drag(
781 &self,
782 start_x: f32,
783 start_y: f32,
784 end_x: f32,
785 end_y: f32,
786 steps: u32,
787 ) -> Result<()> {
788 self.send(TestCommand::Drag {
789 start_x,
790 start_y,
791 end_x,
792 end_y,
793 steps,
794 })?;
795 self.pump()?;
796 Ok(())
797 }
798
799 pub fn external_file_hover(&self, x: f32, y: f32, paths: impl Into<Vec<String>>) -> Result<()> {
800 self.send(TestCommand::ExternalFileHover {
801 x,
802 y,
803 paths: paths.into(),
804 })?;
805 self.pump()?;
806 Ok(())
807 }
808
809 pub fn external_file_drop(&self, x: f32, y: f32, paths: impl Into<Vec<String>>) -> Result<()> {
810 self.send(TestCommand::ExternalFileDrop {
811 x,
812 y,
813 paths: paths.into(),
814 })?;
815 self.pump()?;
816 Ok(())
817 }
818
819 pub fn external_file_cancel(&self) -> Result<()> {
820 self.send(TestCommand::ExternalFileCancel {})?;
821 self.pump()?;
822 Ok(())
823 }
824
825 pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
826 self.send(TestCommand::Scroll { x, y, dx, dy })?;
827 Ok(())
828 }
829
830 pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
831 self.send(TestCommand::PressKey {
832 key: key.to_string(),
833 modifiers,
834 })?;
835 self.pump()?;
836 Ok(())
837 }
838
839 pub fn type_text(&self, text: &str) -> Result<()> {
840 self.send(TestCommand::TypeText {
841 text: text.to_string(),
842 })?;
843 Ok(())
844 }
845
846 pub fn ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> Result<()> {
847 self.send(TestCommand::ImePreedit {
848 text: text.to_string(),
849 cursor_start: cursor.map(|range| range.0),
850 cursor_end: cursor.map(|range| range.1),
851 })?;
852 self.pump()?;
853 Ok(())
854 }
855
856 pub fn ime_commit(&self, text: &str) -> Result<()> {
857 self.send(TestCommand::ImeCommit {
858 text: text.to_string(),
859 })?;
860 self.pump()?;
861 Ok(())
862 }
863
864 pub fn ime_cancel(&self) -> Result<()> {
865 self.send(TestCommand::ImeCancel {})?;
866 self.pump()?;
867 Ok(())
868 }
869
870 pub fn screenshot(&self, path: &str) -> Result<()> {
871 match self.send(TestCommand::CaptureScreenshot {})? {
872 TestResponse::Screenshot {
873 png_base64,
874 width: _,
875 height: _,
876 } => {
877 let bytes = base64::engine::general_purpose::STANDARD
878 .decode(png_base64)
879 .map_err(|e| anyhow!("invalid screenshot payload: {}", e))?;
880 std::fs::write(path, bytes)?;
881 Ok(())
882 }
883 other => Err(anyhow!(
884 "unexpected response to CaptureScreenshot: {:?}",
885 other
886 )),
887 }
888 }
889
890 pub fn get_text(&self) -> Result<Vec<TextItem>> {
891 match self.send(TestCommand::GetText {})? {
892 TestResponse::Text { items } => Ok(items),
893 other => Err(anyhow!("unexpected response: {:?}", other)),
894 }
895 }
896
897 pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
898 match self.send(TestCommand::GetTree {})? {
899 TestResponse::Tree { nodes } => Ok(nodes),
900 other => Err(anyhow!("unexpected response: {:?}", other)),
901 }
902 }
903
904 pub fn wait(&self, ms: u64) -> Result<()> {
905 self.send(TestCommand::Wait { ms })?;
906 Ok(())
907 }
908
909 pub fn pump(&self) -> Result<()> {
910 self.send(TestCommand::Pump {})?;
911 Ok(())
912 }
913
914 pub fn quit(&self) -> Result<()> {
915 let _ = self.send(TestCommand::Quit {});
916 Ok(())
917 }
918
919 pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
923 self.send(TestCommand::SimulateMouseMove { x, y })?;
924 Ok(())
925 }
926
927 pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
929 self.send(TestCommand::SimulateRightClick { x, y })?;
930 Ok(())
931 }
932
933 pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
935 self.send(TestCommand::SimulateResize { width, height })?;
936 Ok(())
937 }
938
939 pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
942 self.tap_text(text)?;
943 self.wait(ms)?;
944 Ok(())
945 }
946
947 pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
948 let items = self.get_text()?;
949 let found = items.iter().any(|t| t.text.contains(needle));
950 if !found {
951 let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
952 return Err(anyhow!(
953 "expected '{}' to be visible, found: {:?}",
954 needle,
955 &all[..all.len().min(20)]
956 ));
957 }
958 Ok(())
959 }
960
961 pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
962 let items = self.get_text()?;
963 let found = items.iter().any(|t| t.text.contains(needle));
964 if found {
965 return Err(anyhow!("expected '{}' to NOT be visible", needle));
966 }
967 Ok(())
968 }
969}