1use std::time::Duration;
4
5use viewpoint_core::Locator;
6
7use crate::error::AssertionError;
8
9const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
11
12pub struct LocatorAssertions<'a> {
14 locator: &'a Locator<'a>,
15 timeout: Duration,
16 is_negated: bool,
17}
18
19impl<'a> LocatorAssertions<'a> {
20 pub fn new(locator: &'a Locator<'a>) -> Self {
22 Self {
23 locator,
24 timeout: DEFAULT_TIMEOUT,
25 is_negated: false,
26 }
27 }
28
29 #[must_use]
31 pub fn timeout(mut self, timeout: Duration) -> Self {
32 self.timeout = timeout;
33 self
34 }
35
36 #[must_use]
40 pub fn negated(mut self) -> Self {
41 self.is_negated = !self.is_negated;
42 self
43 }
44
45 #[must_use]
50 #[allow(clippy::should_implement_trait)]
51 pub fn not(self) -> Self {
52 self.negated()
53 }
54
55 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 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 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 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 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 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 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 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 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 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 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
455fn 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
466async 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}