viewpoint_test/expect/
locator.rs

1//! Locator assertions for testing element state.
2
3use std::time::Duration;
4
5use viewpoint_core::Locator;
6
7use crate::error::AssertionError;
8
9/// Default timeout for assertions.
10const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
11
12/// Assertions for locators (elements).
13pub struct LocatorAssertions<'a> {
14    locator: &'a Locator<'a>,
15    timeout: Duration,
16    is_negated: bool,
17}
18
19impl<'a> LocatorAssertions<'a> {
20    /// Create a new `LocatorAssertions` for the given locator.
21    pub fn new(locator: &'a Locator<'a>) -> Self {
22        Self {
23            locator,
24            timeout: DEFAULT_TIMEOUT,
25            is_negated: false,
26        }
27    }
28
29    /// Set the timeout for this assertion.
30    #[must_use]
31    pub fn timeout(mut self, timeout: Duration) -> Self {
32        self.timeout = timeout;
33        self
34    }
35
36    /// Negate the assertion.
37    ///
38    /// This is an alias for the `not` method to avoid conflict with `std::ops::Not`.
39    #[must_use]
40    pub fn negated(mut self) -> Self {
41        self.is_negated = !self.is_negated;
42        self
43    }
44
45    /// Negate the assertion.
46    ///
47    /// Note: This method name shadows the `Not` trait's method. Use `negated()` if
48    /// you need to avoid this conflict.
49    #[must_use]
50    #[allow(clippy::should_implement_trait)]
51    pub fn not(self) -> Self {
52        self.negated()
53    }
54
55    /// Assert that the element is visible.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if the assertion fails or the element cannot be queried.
60    pub async fn to_be_visible(&self) -> Result<(), AssertionError> {
61        let start = std::time::Instant::now();
62
63        loop {
64            let is_visible = self
65                .locator
66                .is_visible()
67                .await
68                .map_err(|e| AssertionError::new("Failed to check visibility", "visible", e.to_string()))?;
69
70            let expected = !self.is_negated;
71            if is_visible == expected {
72                return Ok(());
73            }
74
75            if start.elapsed() >= self.timeout {
76                return Err(AssertionError::new(
77                    if self.is_negated {
78                        "Element should not be visible"
79                    } else {
80                        "Element should be visible"
81                    },
82                    if expected { "visible" } else { "hidden" },
83                    if is_visible { "visible" } else { "hidden" },
84                ));
85            }
86
87            tokio::time::sleep(Duration::from_millis(100)).await;
88        }
89    }
90
91    /// Assert that the element is hidden.
92    ///
93    /// # Errors
94    ///
95    /// Returns an error if the assertion fails or the element cannot be queried.
96    pub async fn to_be_hidden(&self) -> Result<(), AssertionError> {
97        let start = std::time::Instant::now();
98
99        loop {
100            let is_visible = self
101                .locator
102                .is_visible()
103                .await
104                .map_err(|e| AssertionError::new("Failed to check visibility", "hidden", e.to_string()))?;
105
106            let expected_hidden = !self.is_negated;
107            let is_hidden = !is_visible;
108
109            if is_hidden == expected_hidden {
110                return Ok(());
111            }
112
113            if start.elapsed() >= self.timeout {
114                return Err(AssertionError::new(
115                    if self.is_negated {
116                        "Element should not be hidden"
117                    } else {
118                        "Element should be hidden"
119                    },
120                    if expected_hidden { "hidden" } else { "visible" },
121                    if is_hidden { "hidden" } else { "visible" },
122                ));
123            }
124
125            tokio::time::sleep(Duration::from_millis(100)).await;
126        }
127    }
128
129    /// Assert that the element has the exact text content.
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the assertion fails or the element cannot be queried.
134    pub async fn to_have_text(&self, expected: &str) -> Result<(), AssertionError> {
135        let start = std::time::Instant::now();
136
137        loop {
138            let text = self
139                .locator
140                .text_content()
141                .await
142                .map_err(|e| AssertionError::new("Failed to get text content", expected, e.to_string()))?;
143
144            let actual = text.as_deref().unwrap_or("");
145            let matches = actual.trim() == expected;
146            let expected_match = !self.is_negated;
147
148            if matches == expected_match {
149                return Ok(());
150            }
151
152            if start.elapsed() >= self.timeout {
153                return Err(AssertionError::new(
154                    if self.is_negated {
155                        "Element should not have text"
156                    } else {
157                        "Element should have text"
158                    },
159                    if self.is_negated {
160                        format!("not \"{expected}\"")
161                    } else {
162                        format!("\"{expected}\"")
163                    },
164                    format!("\"{actual}\""),
165                ));
166            }
167
168            tokio::time::sleep(Duration::from_millis(100)).await;
169        }
170    }
171
172    /// Assert that the element contains the specified text.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the assertion fails or the element cannot be queried.
177    pub async fn to_contain_text(&self, expected: &str) -> Result<(), AssertionError> {
178        let start = std::time::Instant::now();
179
180        loop {
181            let text = self
182                .locator
183                .text_content()
184                .await
185                .map_err(|e| AssertionError::new("Failed to get text content", expected, e.to_string()))?;
186
187            let actual = text.as_deref().unwrap_or("");
188            let contains = actual.contains(expected);
189            let expected_match = !self.is_negated;
190
191            if contains == expected_match {
192                return Ok(());
193            }
194
195            if start.elapsed() >= self.timeout {
196                return Err(AssertionError::new(
197                    if self.is_negated {
198                        "Element should not contain text"
199                    } else {
200                        "Element should contain text"
201                    },
202                    if self.is_negated {
203                        format!("not containing \"{expected}\"")
204                    } else {
205                        format!("containing \"{expected}\"")
206                    },
207                    format!("\"{actual}\""),
208                ));
209            }
210
211            tokio::time::sleep(Duration::from_millis(100)).await;
212        }
213    }
214
215    /// Assert that the element has the specified attribute value.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the assertion fails or the element cannot be queried.
220    pub async fn to_have_attribute(&self, name: &str, value: &str) -> Result<(), AssertionError> {
221        let start = std::time::Instant::now();
222
223        loop {
224            let actual = self.get_attribute(name).await?;
225            let matches = actual.as_deref() == Some(value);
226            let expected_match = !self.is_negated;
227
228            if matches == expected_match {
229                return Ok(());
230            }
231
232            if start.elapsed() >= self.timeout {
233                return Err(AssertionError::new(
234                    if self.is_negated {
235                        format!("Element should not have attribute {name}=\"{value}\"")
236                    } else {
237                        format!("Element should have attribute {name}=\"{value}\"")
238                    },
239                    if self.is_negated {
240                        format!("not {name}=\"{value}\"")
241                    } else {
242                        format!("{name}=\"{value}\"")
243                    },
244                    match actual {
245                        Some(v) => format!("{name}=\"{v}\""),
246                        None => format!("{name} not present"),
247                    },
248                ));
249            }
250
251            tokio::time::sleep(Duration::from_millis(100)).await;
252        }
253    }
254
255    /// Assert that the element has the specified class.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if the assertion fails or the element cannot be queried.
260    pub async fn to_have_class(&self, class_name: &str) -> Result<(), AssertionError> {
261        let start = std::time::Instant::now();
262
263        loop {
264            let class_attr = self.get_attribute("class").await?;
265            let classes = class_attr.as_deref().unwrap_or("");
266            let has_class = classes.split_whitespace().any(|c| c == class_name);
267            let expected_match = !self.is_negated;
268
269            if has_class == expected_match {
270                return Ok(());
271            }
272
273            if start.elapsed() >= self.timeout {
274                return Err(AssertionError::new(
275                    if self.is_negated {
276                        format!("Element should not have class \"{class_name}\"")
277                    } else {
278                        format!("Element should have class \"{class_name}\"")
279                    },
280                    if self.is_negated {
281                        format!("not containing class \"{class_name}\"")
282                    } else {
283                        format!("class \"{class_name}\"")
284                    },
285                    format!("classes: \"{classes}\""),
286                ));
287            }
288
289            tokio::time::sleep(Duration::from_millis(100)).await;
290        }
291    }
292
293    /// Assert that the element is enabled.
294    ///
295    /// # Errors
296    ///
297    /// Returns an error if the assertion fails or the element cannot be queried.
298    pub async fn to_be_enabled(&self) -> Result<(), AssertionError> {
299        let start = std::time::Instant::now();
300
301        loop {
302            let is_enabled = self.is_enabled().await?;
303            let expected_enabled = !self.is_negated;
304
305            if is_enabled == expected_enabled {
306                return Ok(());
307            }
308
309            if start.elapsed() >= self.timeout {
310                return Err(AssertionError::new(
311                    if self.is_negated {
312                        "Element should not be enabled"
313                    } else {
314                        "Element should be enabled"
315                    },
316                    if expected_enabled { "enabled" } else { "disabled" },
317                    if is_enabled { "enabled" } else { "disabled" },
318                ));
319            }
320
321            tokio::time::sleep(Duration::from_millis(100)).await;
322        }
323    }
324
325    /// Assert that the element is disabled.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the assertion fails or the element cannot be queried.
330    pub async fn to_be_disabled(&self) -> Result<(), AssertionError> {
331        let start = std::time::Instant::now();
332
333        loop {
334            let is_enabled = self.is_enabled().await?;
335            let expected_disabled = !self.is_negated;
336            let is_disabled = !is_enabled;
337
338            if is_disabled == expected_disabled {
339                return Ok(());
340            }
341
342            if start.elapsed() >= self.timeout {
343                return Err(AssertionError::new(
344                    if self.is_negated {
345                        "Element should not be disabled"
346                    } else {
347                        "Element should be disabled"
348                    },
349                    if expected_disabled { "disabled" } else { "enabled" },
350                    if is_disabled { "disabled" } else { "enabled" },
351                ));
352            }
353
354            tokio::time::sleep(Duration::from_millis(100)).await;
355        }
356    }
357
358    /// Assert that the element is checked (for checkboxes/radios).
359    ///
360    /// # Errors
361    ///
362    /// Returns an error if the assertion fails or the element cannot be queried.
363    pub async fn to_be_checked(&self) -> Result<(), AssertionError> {
364        let start = std::time::Instant::now();
365
366        loop {
367            let is_checked = self
368                .locator
369                .is_checked()
370                .await
371                .map_err(|e| AssertionError::new("Failed to check checked state", "checked", e.to_string()))?;
372
373            let expected_checked = !self.is_negated;
374
375            if is_checked == expected_checked {
376                return Ok(());
377            }
378
379            if start.elapsed() >= self.timeout {
380                return Err(AssertionError::new(
381                    if self.is_negated {
382                        "Element should not be checked"
383                    } else {
384                        "Element should be checked"
385                    },
386                    if expected_checked { "checked" } else { "unchecked" },
387                    if is_checked { "checked" } else { "unchecked" },
388                ));
389            }
390
391            tokio::time::sleep(Duration::from_millis(100)).await;
392        }
393    }
394
395    // =========================================================================
396    // Internal helpers
397    // =========================================================================
398
399    async fn get_attribute(&self, name: &str) -> Result<Option<String>, AssertionError> {
400        let page = self.locator.page();
401        let selector = self.locator.selector();
402
403        // Build JS to get attribute
404        let js = format!(
405            r"(function() {{
406                const elements = {};
407                if (elements.length === 0) return {{ found: false }};
408                const el = elements[0];
409                const value = el.getAttribute({});
410                return {{ found: true, value: value }};
411            }})()",
412            selector.to_js_expression(),
413            js_string_literal(name)
414        );
415
416        let result = evaluate_js(page, &js).await?;
417
418        let found = result.get("found").and_then(serde_json::Value::as_bool).unwrap_or(false);
419        if !found {
420            return Ok(None);
421        }
422
423        Ok(result.get("value").and_then(|v| v.as_str()).map(String::from))
424    }
425
426    async fn is_enabled(&self) -> Result<bool, AssertionError> {
427        let page = self.locator.page();
428        let selector = self.locator.selector();
429
430        let js = format!(
431            r"(function() {{
432                const elements = {};
433                if (elements.length === 0) return {{ found: false }};
434                const el = elements[0];
435                return {{ found: true, enabled: !el.disabled }};
436            }})()",
437            selector.to_js_expression()
438        );
439
440        let result = evaluate_js(page, &js).await?;
441
442        let found = result.get("found").and_then(serde_json::Value::as_bool).unwrap_or(false);
443        if !found {
444            return Err(AssertionError::new(
445                "Element not found",
446                "element to exist",
447                "element not found",
448            ));
449        }
450
451        Ok(result.get("enabled").and_then(serde_json::Value::as_bool).unwrap_or(true))
452    }
453}
454
455// Helper to escape strings for JavaScript
456fn js_string_literal(s: &str) -> String {
457    let escaped = s
458        .replace('\\', "\\\\")
459        .replace('\'', "\\'")
460        .replace('\n', "\\n")
461        .replace('\r', "\\r")
462        .replace('\t', "\\t");
463    format!("'{escaped}'")
464}
465
466// Helper to evaluate JavaScript on a page
467async fn evaluate_js(
468    page: &viewpoint_core::Page,
469    expression: &str,
470) -> Result<serde_json::Value, AssertionError> {
471    use viewpoint_cdp::protocol::runtime::EvaluateParams;
472
473    if page.is_closed() {
474        return Err(AssertionError::new(
475            "Page is closed",
476            "page to be open",
477            "page is closed",
478        ));
479    }
480
481    let params = EvaluateParams {
482        expression: expression.to_string(),
483        object_group: None,
484        include_command_line_api: None,
485        silent: Some(true),
486        context_id: None,
487        return_by_value: Some(true),
488        await_promise: Some(false),
489    };
490
491    let result: viewpoint_cdp::protocol::runtime::EvaluateResult = page
492        .connection()
493        .send_command("Runtime.evaluate", Some(params), Some(page.session_id()))
494        .await
495        .map_err(|e| AssertionError::new("Failed to evaluate JavaScript", "success", e.to_string()))?;
496
497    if let Some(exception) = result.exception_details {
498        return Err(AssertionError::new(
499            "JavaScript error",
500            "no error",
501            exception.text,
502        ));
503    }
504
505    result
506        .result
507        .value
508        .ok_or_else(|| AssertionError::new("No result from JavaScript", "a value", "null/undefined"))
509}