1use crate::core::constants::TIMING;
7use crate::core::engine::{EngineAdapter, EngineError};
8use crate::core::navigation::is_navigation_error;
9use crate::core::readiness::{CheckRecord, Deadline, ReadinessOutcome};
10use serde_json::json;
11use std::time::{Duration, Instant};
12
13#[derive(Debug, Clone)]
15pub struct NavigationOptions {
16 pub wait_until: WaitUntil,
18 pub timeout: Duration,
20 pub wait_for_stable_url_before: bool,
22 pub wait_for_stable_url_after: bool,
24 pub verify: bool,
26 pub verification_timeout: Duration,
28 pub stable_checks: u32,
30 pub check_interval: Duration,
32}
33
34impl Default for NavigationOptions {
35 fn default() -> Self {
36 Self {
37 wait_until: WaitUntil::DomContentLoaded,
38 timeout: TIMING.navigation_timeout,
39 wait_for_stable_url_before: true,
40 wait_for_stable_url_after: true,
41 verify: true,
42 verification_timeout: TIMING.verification_timeout,
43 stable_checks: 3,
44 check_interval: Duration::from_secs(1),
45 }
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum WaitUntil {
52 #[default]
54 DomContentLoaded,
55 Load,
57 NetworkIdle,
59}
60
61impl std::fmt::Display for WaitUntil {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 match self {
64 WaitUntil::DomContentLoaded => write!(f, "domcontentloaded"),
65 WaitUntil::Load => write!(f, "load"),
66 WaitUntil::NetworkIdle => write!(f, "networkidle"),
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct NavigationVerificationResult {
74 pub verified: bool,
76 pub actual_url: String,
78 pub reason: String,
80 pub attempts: u32,
82}
83
84#[derive(Debug, Clone)]
86pub struct NavigationResult {
87 pub navigated: bool,
89 pub verified: bool,
91 pub actual_url: Option<String>,
93 pub reason: Option<String>,
95 pub readiness: Option<ReadinessOutcome>,
99}
100
101impl NavigationResult {
102 pub fn success(actual_url: String) -> Self {
104 Self {
105 navigated: true,
106 verified: true,
107 actual_url: Some(actual_url),
108 reason: Some("navigation completed".to_string()),
109 readiness: None,
110 }
111 }
112
113 pub fn interrupted(reason: impl Into<String>) -> Self {
115 Self {
116 navigated: false,
117 verified: false,
118 actual_url: None,
119 reason: Some(reason.into()),
120 readiness: None,
121 }
122 }
123
124 #[must_use]
130 pub fn with_readiness(mut self, readiness: ReadinessOutcome) -> Self {
131 if !readiness.ready {
132 self.verified = false;
133 self.reason = Some(readiness.summary());
134 }
135 self.readiness = Some(readiness);
136 self
137 }
138}
139
140pub async fn verify_navigation(
153 adapter: &dyn EngineAdapter,
154 expected_url: Option<&str>,
155 start_url: &str,
156 options: &NavigationOptions,
157) -> Result<NavigationVerificationResult, EngineError> {
158 let deadline = Deadline::new(options.verification_timeout);
159 verify_navigation_within(adapter, expected_url, start_url, options, &deadline).await
160}
161
162pub async fn verify_navigation_within(
176 adapter: &dyn EngineAdapter,
177 expected_url: Option<&str>,
178 start_url: &str,
179 options: &NavigationOptions,
180 deadline: &Deadline,
181) -> Result<NavigationVerificationResult, EngineError> {
182 let budget = options.verification_timeout.min(deadline.remaining());
185 let start_time = Instant::now();
186 let mut attempts = 0u32;
187
188 while start_time.elapsed() < budget {
189 attempts += 1;
190
191 let actual_url = match adapter.url().await {
192 Ok(url) => url,
193 Err(e) if is_navigation_error(&e.to_string()) => {
194 return Ok(NavigationVerificationResult {
195 verified: false,
196 actual_url: String::new(),
197 reason: "error during verification".to_string(),
198 attempts,
199 });
200 }
201 Err(e) => return Err(e),
202 };
203
204 if let Some(expected) = expected_url {
206 if actual_url == expected {
207 return Ok(NavigationVerificationResult {
208 verified: true,
209 actual_url,
210 reason: "exact URL match".to_string(),
211 attempts,
212 });
213 }
214
215 if actual_url.contains(expected) || actual_url.starts_with(expected) {
216 return Ok(NavigationVerificationResult {
217 verified: true,
218 actual_url,
219 reason: "URL pattern match".to_string(),
220 attempts,
221 });
222 }
223 } else {
224 if actual_url != start_url {
226 return Ok(NavigationVerificationResult {
227 verified: true,
228 actual_url,
229 reason: "URL changed from start".to_string(),
230 attempts,
231 });
232 }
233 }
234
235 deadline.sleep_at_most(Duration::from_millis(100)).await;
236 }
237
238 let actual_url = adapter.url().await?;
240
241 Ok(NavigationVerificationResult {
242 verified: false,
243 actual_url: actual_url.clone(),
244 reason: format!(
245 "URL mismatch: expected {:?}, got \"{}\"",
246 expected_url, actual_url
247 ),
248 attempts,
249 })
250}
251
252pub async fn url_stable_within(
265 adapter: &dyn EngineAdapter,
266 options: &NavigationOptions,
267 deadline: &Deadline,
268 name: &str,
269) -> Result<CheckRecord, EngineError> {
270 let started_at_ms = deadline.elapsed_ms();
271 let mut stable_count = 0u32;
272 let mut last_url = adapter.url().await?;
273
274 while stable_count < options.stable_checks {
275 if deadline.expired() {
276 let elapsed = deadline.elapsed_ms() - started_at_ms;
277 return Ok(CheckRecord::unsatisfied(
278 name,
279 json!({
280 "url": last_url,
281 "stableChecks": stable_count,
282 "requiredChecks": options.stable_checks,
283 "reason": "deadline reached",
284 }),
285 )
286 .with_timing(started_at_ms, elapsed));
287 }
288
289 deadline.sleep_at_most(options.check_interval).await;
290
291 let current_url = adapter.url().await?;
292
293 if current_url == last_url {
294 stable_count += 1;
295 } else {
296 stable_count = 0;
297 last_url = current_url;
298 }
299 }
300
301 let elapsed = deadline.elapsed_ms() - started_at_ms;
302 Ok(CheckRecord::satisfied(
303 name,
304 json!({ "url": last_url, "stableChecks": stable_count }),
305 )
306 .with_timing(started_at_ms, elapsed))
307}
308
309pub async fn wait_for_url_stabilization(
321 adapter: &dyn EngineAdapter,
322 options: &NavigationOptions,
323 reason: &str,
324) -> Result<bool, EngineError> {
325 let deadline = Deadline::new(options.timeout);
326 let record = url_stable_within(adapter, options, &deadline, reason).await?;
327 Ok(record.satisfied)
328}
329
330pub async fn goto(
342 adapter: &dyn EngineAdapter,
343 url: &str,
344 options: &NavigationOptions,
345) -> Result<NavigationResult, EngineError> {
346 let start_url = adapter.url().await?;
347 let deadline = Deadline::new(options.timeout);
351 let mut readiness = ReadinessOutcome::new(&deadline);
352
353 if options.wait_for_stable_url_before {
355 let record = url_stable_within(adapter, options, &deadline, "url_stable_before").await?;
356 readiness.record(record);
357 }
358
359 match adapter.goto(url).await {
361 Ok(_) => {}
362 Err(e) if is_navigation_error(&e.to_string()) => {
363 return Ok(NavigationResult::interrupted("navigation was interrupted"));
364 }
365 Err(e) => return Err(e),
366 }
367
368 if options.wait_for_stable_url_after {
370 let record = url_stable_within(adapter, options, &deadline, "url_stable_after").await?;
371 readiness.record(record);
372 }
373
374 if options.verify {
376 if deadline.expired() {
377 readiness.defer("verify_navigation");
380 } else {
381 let started_at_ms = deadline.elapsed_ms();
382 let verification =
383 verify_navigation_within(adapter, Some(url), &start_url, options, &deadline)
384 .await?;
385 let detail = json!({
386 "actualUrl": verification.actual_url,
387 "reason": verification.reason,
388 "attempts": verification.attempts,
389 });
390 let record = if verification.verified {
391 CheckRecord::satisfied("verify_navigation", detail)
392 } else {
393 CheckRecord::unsatisfied("verify_navigation", detail)
394 }
395 .with_timing(started_at_ms, deadline.elapsed_ms() - started_at_ms);
396 readiness.record(record);
397 let readiness = readiness.finish(&deadline);
398
399 return Ok(NavigationResult {
400 navigated: true,
401 verified: verification.verified,
402 actual_url: Some(verification.actual_url.clone()),
403 reason: Some(if readiness.ready {
404 verification.reason
405 } else {
406 readiness.summary()
407 }),
408 readiness: Some(readiness),
409 });
410 }
411 }
412
413 let readiness = readiness.finish(&deadline);
414 let actual_url = adapter.url().await?;
415 Ok(NavigationResult::success(actual_url).with_readiness(readiness))
416}
417
418pub async fn wait_for_navigation(
429 adapter: &dyn EngineAdapter,
430 timeout_ms: u64,
431) -> Result<bool, EngineError> {
432 match adapter.wait_for_navigation(timeout_ms).await {
433 Ok(_) => Ok(true),
434 Err(e) if is_navigation_error(&e.to_string()) => Ok(false),
435 Err(e) => Err(e),
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::core::stub_engine::StubEngine;
443
444 #[test]
445 fn navigation_options_default() {
446 let options = NavigationOptions::default();
447 assert_eq!(options.wait_until, WaitUntil::DomContentLoaded);
448 assert!(options.wait_for_stable_url_before);
449 assert!(options.wait_for_stable_url_after);
450 assert!(options.verify);
451 assert_eq!(options.stable_checks, 3);
452 }
453
454 #[test]
455 fn wait_until_display() {
456 assert_eq!(WaitUntil::DomContentLoaded.to_string(), "domcontentloaded");
457 assert_eq!(WaitUntil::Load.to_string(), "load");
458 assert_eq!(WaitUntil::NetworkIdle.to_string(), "networkidle");
459 }
460
461 #[test]
462 fn navigation_result_success() {
463 let result = NavigationResult::success("https://example.com".to_string());
464 assert!(result.navigated);
465 assert!(result.verified);
466 assert_eq!(result.actual_url, Some("https://example.com".to_string()));
467 }
468
469 #[test]
470 fn navigation_result_interrupted() {
471 let result = NavigationResult::interrupted("page was closed");
472 assert!(!result.navigated);
473 assert!(!result.verified);
474 assert!(result.actual_url.is_none());
475 assert_eq!(result.reason, Some("page was closed".to_string()));
476 }
477
478 fn quick_options(timeout_ms: u64) -> NavigationOptions {
480 NavigationOptions {
481 timeout: Duration::from_millis(timeout_ms),
482 verification_timeout: Duration::from_millis(timeout_ms),
483 stable_checks: 2,
484 check_interval: Duration::from_millis(5),
485 ..NavigationOptions::default()
486 }
487 }
488
489 #[tokio::test]
490 async fn an_unstable_url_is_not_reported_as_verified() {
491 let engine = StubEngine::never_stable();
495 let options = NavigationOptions {
496 wait_for_stable_url_before: false,
497 verify: false,
498 ..quick_options(60)
499 };
500
501 let result = goto(&engine, "https://example.com/target", &options)
502 .await
503 .unwrap();
504
505 assert!(result.navigated);
506 assert!(!result.verified);
507 let readiness = result.readiness.expect("readiness evidence");
508 assert!(!readiness.ready);
509 assert_eq!(readiness.failed, vec!["url_stable_after".to_string()]);
510 assert!(result.reason.unwrap().contains("url_stable_after"));
511 }
512
513 #[tokio::test]
514 async fn a_stable_url_is_recorded_as_satisfied_evidence() {
515 let engine = StubEngine::fixed("https://example.com/target");
516 let options = NavigationOptions {
517 wait_for_stable_url_before: false,
518 verify: false,
519 ..quick_options(2_000)
520 };
521
522 let result = goto(&engine, "https://example.com/target", &options)
523 .await
524 .unwrap();
525
526 assert!(result.verified);
527 let readiness = result.readiness.expect("readiness evidence");
528 assert!(readiness.ready);
529 assert_eq!(readiness.satisfied, vec!["url_stable_after".to_string()]);
530 assert_eq!(readiness.evidence.len(), 1);
531 assert_eq!(readiness.evidence[0].detail["stableChecks"], 2);
532 }
533
534 #[tokio::test]
535 async fn goto_shares_one_budget_across_every_check() {
536 let engine = StubEngine::never_stable();
540 let options = quick_options(60);
541
542 let started = Instant::now();
543 let result = goto(&engine, "https://example.com/target", &options)
544 .await
545 .unwrap();
546 let elapsed = started.elapsed();
547
548 assert!(
549 elapsed < Duration::from_millis(120),
550 "goto took {elapsed:?}, which is more than one 60ms budget"
551 );
552 let readiness = result.readiness.expect("readiness evidence");
553 assert!(readiness.elapsed_ms >= 60);
554 assert_eq!(
555 readiness.failed,
556 vec![
557 "url_stable_before".to_string(),
558 "url_stable_after".to_string()
559 ]
560 );
561 }
562
563 #[tokio::test]
564 async fn verification_that_never_ran_is_pending_not_satisfied() {
565 let engine = StubEngine::never_stable();
566 let options = quick_options(40);
567
568 let result = goto(&engine, "https://example.com/target", &options)
569 .await
570 .unwrap();
571
572 let readiness = result.readiness.expect("readiness evidence");
573 assert_eq!(readiness.pending, vec!["verify_navigation".to_string()]);
574 assert!(!readiness
575 .satisfied
576 .contains(&"verify_navigation".to_string()));
577 }
578
579 #[tokio::test]
580 async fn verification_never_outlives_the_navigation_budget() {
581 let engine = StubEngine::fixed("https://example.com/start");
584 let options = NavigationOptions {
585 verification_timeout: Duration::from_secs(30),
586 ..quick_options(50)
587 };
588 let deadline = Deadline::new(Duration::from_millis(50));
589
590 let started = Instant::now();
591 let verification = verify_navigation_within(
592 &engine,
593 Some("https://example.com/target"),
594 "https://example.com/start",
595 &options,
596 &deadline,
597 )
598 .await
599 .unwrap();
600
601 assert!(!verification.verified);
602 assert!(
603 started.elapsed() < Duration::from_millis(500),
604 "verification outlived the navigation budget"
605 );
606 }
607
608 #[tokio::test]
609 async fn stabilization_evidence_carries_its_timing_window() {
610 let engine = StubEngine::fixed("https://example.com/target");
611 let deadline = Deadline::new(Duration::from_millis(500));
612 let options = quick_options(500);
613
614 let record = url_stable_within(&engine, &options, &deadline, "url_stable_after")
615 .await
616 .unwrap();
617
618 assert!(record.satisfied);
619 assert_eq!(record.name, "url_stable_after");
620 assert!(record.elapsed_ms >= 5);
621 }
622
623 #[tokio::test]
624 async fn the_legacy_stabilization_wrapper_still_reports_a_bool() {
625 let stable = StubEngine::fixed("https://example.com/target");
626 let unstable = StubEngine::never_stable();
627 let options = quick_options(40);
628
629 assert!(wait_for_url_stabilization(&stable, &options, "after")
630 .await
631 .unwrap());
632 assert!(!wait_for_url_stabilization(&unstable, &options, "after")
633 .await
634 .unwrap());
635 }
636}