stygian_browser/page.rs
1//! Page and browsing context management for isolated, parallel scraping
2//!
3//! Each `BrowserContext` (future) is an incognito-style isolation boundary (separate
4//! cookies, localStorage, cache). Each context can contain many [`PageHandle`]s
5//! (tabs). Both types clean up their CDP resources automatically on drop.
6//!
7//! ## Resource blocking
8//!
9//! Pass a [`ResourceFilter`] to [`PageHandle::set_resource_filter`] to intercept
10//! and block specific request types (images, fonts, CSS) before page load —
11//! significantly reducing page load times for text-only scraping.
12//!
13//! ## Wait strategies
14//!
15//! [`PageHandle`] exposes three wait strategies via [`WaitUntil`]:
16//! - `DomContentLoaded` — fires when the HTML is parsed
17//! - `NetworkIdle` — fires when there are ≤2 in-flight requests for 500 ms
18//! - `Selector(css)` — fires when a CSS selector matches an element
19//!
20//! # Example
21//!
22//! ```no_run
23//! use stygian_browser::{BrowserPool, BrowserConfig};
24//! use stygian_browser::page::{ResourceFilter, WaitUntil};
25//! use std::time::Duration;
26//!
27//! # async fn run() -> stygian_browser::error::Result<()> {
28//! let pool = BrowserPool::new(BrowserConfig::default()).await?;
29//! let handle = pool.acquire().await?;
30//!
31//! let mut page = handle.browser().expect("valid browser").new_page().await?;
32//! page.set_resource_filter(ResourceFilter::block_media()).await?;
33//! page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
34//! let title = page.title().await?;
35//! println!("title: {title}");
36//! handle.release().await;
37//! # Ok(())
38//! # }
39//! ```
40
41use std::sync::{
42 Arc,
43 atomic::{AtomicU16, Ordering},
44};
45use std::time::Duration;
46
47use chromiumoxide::Page;
48use tokio::time::timeout;
49use tracing::{debug, warn};
50
51use crate::error::{BrowserError, Result};
52
53// ─── ResourceType ─────────────────────────────────────────────────────────────
54
55/// CDP resource types that can be intercepted.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ResourceType {
58 /// `<img>`, `<picture>`, background images
59 Image,
60 /// Web fonts loaded via CSS `@font-face`
61 Font,
62 /// External CSS stylesheets
63 Stylesheet,
64 /// Media files (audio/video)
65 Media,
66}
67
68impl ResourceType {
69 /// Returns the string used in CDP `Network.requestIntercepted` events.
70 pub const fn as_cdp_str(&self) -> &'static str {
71 match self {
72 Self::Image => "Image",
73 Self::Font => "Font",
74 Self::Stylesheet => "Stylesheet",
75 Self::Media => "Media",
76 }
77 }
78}
79
80// ─── ResourceFilter ───────────────────────────────────────────────────────────
81
82/// Set of resource types to block from loading.
83///
84/// # Example
85///
86/// ```
87/// use stygian_browser::page::ResourceFilter;
88/// let filter = ResourceFilter::block_media();
89/// assert!(filter.should_block("Image"));
90/// ```
91#[derive(Debug, Clone, Default)]
92pub struct ResourceFilter {
93 blocked: Vec<ResourceType>,
94}
95
96impl ResourceFilter {
97 /// Block all media resources (images, fonts, CSS, audio/video).
98 pub fn block_media() -> Self {
99 Self {
100 blocked: vec![
101 ResourceType::Image,
102 ResourceType::Font,
103 ResourceType::Stylesheet,
104 ResourceType::Media,
105 ],
106 }
107 }
108
109 /// Block only images and fonts (keep styles for layout-sensitive work).
110 pub fn block_images_and_fonts() -> Self {
111 Self {
112 blocked: vec![ResourceType::Image, ResourceType::Font],
113 }
114 }
115
116 /// Add a resource type to the block list.
117 #[must_use]
118 pub fn block(mut self, resource: ResourceType) -> Self {
119 if !self.blocked.contains(&resource) {
120 self.blocked.push(resource);
121 }
122 self
123 }
124
125 /// Returns `true` if the given CDP resource type string should be blocked.
126 pub fn should_block(&self, cdp_type: &str) -> bool {
127 self.blocked
128 .iter()
129 .any(|r| r.as_cdp_str().eq_ignore_ascii_case(cdp_type))
130 }
131
132 /// Returns `true` if no resource types are blocked.
133 pub const fn is_empty(&self) -> bool {
134 self.blocked.is_empty()
135 }
136}
137
138// ─── WaitUntil ────────────────────────────────────────────────────────────────
139
140/// Condition to wait for after a navigation.
141///
142/// # Example
143///
144/// ```
145/// use stygian_browser::page::WaitUntil;
146/// let w = WaitUntil::Selector("#main".to_string());
147/// assert!(matches!(w, WaitUntil::Selector(_)));
148/// ```
149#[derive(Debug, Clone)]
150pub enum WaitUntil {
151 /// Wait for the `DOMContentLoaded` event.
152 DomContentLoaded,
153 /// Wait until there are ≤2 active network requests for at least 500 ms.
154 NetworkIdle,
155 /// Wait until `document.querySelector(selector)` returns a non-null element.
156 Selector(String),
157}
158
159// ─── PageHandle ───────────────────────────────────────────────────────────────
160
161/// A handle to an open browser tab.
162///
163/// On drop the underlying page is closed automatically.
164///
165/// # Example
166///
167/// ```no_run
168/// use stygian_browser::{BrowserPool, BrowserConfig};
169/// use stygian_browser::page::WaitUntil;
170/// use std::time::Duration;
171///
172/// # async fn run() -> stygian_browser::error::Result<()> {
173/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
174/// let handle = pool.acquire().await?;
175/// let mut page = handle.browser().expect("valid browser").new_page().await?;
176/// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
177/// let html = page.content().await?;
178/// drop(page); // closes the tab
179/// handle.release().await;
180/// # Ok(())
181/// # }
182/// ```
183pub struct PageHandle {
184 page: Page,
185 cdp_timeout: Duration,
186 /// HTTP status code of the most recent main-frame navigation, or `0` if not
187 /// yet captured. Written atomically by the listener spawned in `navigate()`.
188 last_status_code: Arc<AtomicU16>,
189}
190
191impl PageHandle {
192 /// Wrap a raw chromiumoxide [`Page`] in a handle.
193 pub(crate) fn new(page: Page, cdp_timeout: Duration) -> Self {
194 Self {
195 page,
196 cdp_timeout,
197 last_status_code: Arc::new(AtomicU16::new(0)),
198 }
199 }
200
201 /// Navigate to `url` and wait for `condition` within `nav_timeout`.
202 ///
203 /// # Errors
204 ///
205 /// Returns [`BrowserError::NavigationFailed`] if the navigation times out or
206 /// the CDP call fails.
207 pub async fn navigate(
208 &mut self,
209 url: &str,
210 condition: WaitUntil,
211 nav_timeout: Duration,
212 ) -> Result<()> {
213 use chromiumoxide::cdp::browser_protocol::network::{
214 EventResponseReceived, ResourceType as NetworkResourceType,
215 };
216 use chromiumoxide::cdp::browser_protocol::page::EventLoadEventFired;
217 use futures::StreamExt;
218
219 let url_owned = url.to_string();
220
221 // Reset the stored status before each navigation so stale codes are
222 // not returned if the new navigation fails before headers arrive.
223 self.last_status_code.store(0, Ordering::Release);
224
225 // Subscribe to Network.responseReceived *before* goto() so no events
226 // are missed. The listener runs in a detached task and stores the
227 // first Document-type response status atomically.
228 let page_for_listener = self.page.clone();
229 let status_capture = Arc::clone(&self.last_status_code);
230 match page_for_listener
231 .event_listener::<EventResponseReceived>()
232 .await
233 {
234 Ok(mut stream) => {
235 tokio::spawn(async move {
236 while let Some(event) = stream.next().await {
237 if event.r#type == NetworkResourceType::Document {
238 let code = u16::try_from(event.response.status).unwrap_or(0);
239 if code > 0 {
240 status_capture.store(code, Ordering::Release);
241 }
242 break;
243 }
244 }
245 });
246 }
247 Err(e) => {
248 warn!("status-code capture unavailable: {e}");
249 }
250 }
251
252 let navigate_fut = async {
253 self.page
254 .goto(url)
255 .await
256 .map_err(|e| BrowserError::NavigationFailed {
257 url: url_owned.clone(),
258 reason: e.to_string(),
259 })?;
260
261 match &condition {
262 WaitUntil::DomContentLoaded | WaitUntil::NetworkIdle => {
263 // chromiumoxide's goto() already waits for load; for
264 // NetworkIdle we listen for the load event as a proxy
265 // (full idle detection requires request interception which
266 // is setup separately).
267 let mut events = self
268 .page
269 .event_listener::<EventLoadEventFired>()
270 .await
271 .map_err(|e| BrowserError::NavigationFailed {
272 url: url_owned.clone(),
273 reason: e.to_string(),
274 })?;
275 // consume first event or treat as already fired
276 let _ = events.next().await;
277 }
278 WaitUntil::Selector(css) => {
279 self.wait_for_selector(css, nav_timeout).await?;
280 }
281 }
282 Ok(())
283 };
284
285 timeout(nav_timeout, navigate_fut)
286 .await
287 .map_err(|_| BrowserError::NavigationFailed {
288 url: url.to_string(),
289 reason: format!("navigation timed out after {nav_timeout:?}"),
290 })?
291 }
292
293 /// Wait until `document.querySelector(selector)` is non-null (`timeout`).
294 ///
295 /// # Errors
296 ///
297 /// Returns [`BrowserError::NavigationFailed`] if the selector is not found
298 /// within the given timeout.
299 pub async fn wait_for_selector(&self, selector: &str, wait_timeout: Duration) -> Result<()> {
300 let selector_owned = selector.to_string();
301 let poll = async {
302 loop {
303 if self.page.find_element(selector_owned.clone()).await.is_ok() {
304 return Ok(());
305 }
306 tokio::time::sleep(Duration::from_millis(100)).await;
307 }
308 };
309
310 timeout(wait_timeout, poll)
311 .await
312 .map_err(|_| BrowserError::NavigationFailed {
313 url: String::new(),
314 reason: format!("selector '{selector_owned}' not found within {wait_timeout:?}"),
315 })?
316 }
317
318 /// Set a resource filter to block specific network request types.
319 ///
320 /// **Note:** Requires Network.enable; called automatically.
321 ///
322 /// # Errors
323 ///
324 /// Returns a [`BrowserError::CdpError`] if the CDP call fails.
325 pub async fn set_resource_filter(&mut self, filter: ResourceFilter) -> Result<()> {
326 use chromiumoxide::cdp::browser_protocol::fetch::{EnableParams, RequestPattern};
327
328 if filter.is_empty() {
329 return Ok(());
330 }
331
332 // Both builders are infallible — they return the struct directly (not Result)
333 let pattern = RequestPattern::builder().url_pattern("*").build();
334 let params = EnableParams::builder()
335 .patterns(vec![pattern])
336 .handle_auth_requests(false)
337 .build();
338
339 timeout(self.cdp_timeout, self.page.execute::<EnableParams>(params))
340 .await
341 .map_err(|_| BrowserError::Timeout {
342 operation: "Fetch.enable".to_string(),
343 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
344 })?
345 .map_err(|e| BrowserError::CdpError {
346 operation: "Fetch.enable".to_string(),
347 message: e.to_string(),
348 })?;
349
350 debug!("Resource filter active: {:?}", filter);
351 Ok(())
352 }
353
354 /// Return the current page URL (post-navigation, post-redirect).
355 ///
356 /// Delegates to the CDP `Target.getTargetInfo` binding already used
357 /// internally by [`save_cookies`](Self::save_cookies); no extra network
358 /// request is made. Returns an empty string if the URL is not yet set
359 /// (e.g. on a blank tab before the first navigation).
360 ///
361 /// # Errors
362 ///
363 /// Returns [`BrowserError::CdpError`] if the underlying CDP call fails, or
364 /// [`BrowserError::Timeout`] if it exceeds `cdp_timeout`.
365 ///
366 /// # Example
367 ///
368 /// ```no_run
369 /// use stygian_browser::{BrowserPool, BrowserConfig};
370 /// use stygian_browser::page::WaitUntil;
371 /// use std::time::Duration;
372 ///
373 /// # async fn run() -> stygian_browser::error::Result<()> {
374 /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
375 /// let handle = pool.acquire().await?;
376 /// let mut page = handle.browser().expect("valid browser").new_page().await?;
377 /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
378 /// let url = page.url().await?;
379 /// println!("Final URL after redirects: {url}");
380 /// # Ok(())
381 /// # }
382 /// ```
383 pub async fn url(&self) -> Result<String> {
384 timeout(self.cdp_timeout, self.page.url())
385 .await
386 .map_err(|_| BrowserError::Timeout {
387 operation: "page.url".to_string(),
388 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
389 })?
390 .map_err(|e| BrowserError::CdpError {
391 operation: "page.url".to_string(),
392 message: e.to_string(),
393 })
394 .map(Option::unwrap_or_default)
395 }
396
397 /// Return the HTTP status code of the most recent main-frame navigation.
398 ///
399 /// The status is captured from the `Network.responseReceived` CDP event
400 /// wired up inside [`navigate`](Self::navigate), so it reflects the
401 /// *final* response after any server-side redirects.
402 ///
403 /// Returns `None` if the status was not captured — for example on `file://`
404 /// navigations, when [`navigate`](Self::navigate) has not yet been called,
405 /// or if the network event subscription failed.
406 ///
407 /// # Errors
408 ///
409 /// This method is infallible; the `Result` wrapper is kept for API
410 /// consistency with other `PageHandle` methods.
411 ///
412 /// # Example
413 ///
414 /// ```no_run
415 /// use stygian_browser::{BrowserPool, BrowserConfig};
416 /// use stygian_browser::page::WaitUntil;
417 /// use std::time::Duration;
418 ///
419 /// # async fn run() -> stygian_browser::error::Result<()> {
420 /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
421 /// let handle = pool.acquire().await?;
422 /// let mut page = handle.browser().expect("valid browser").new_page().await?;
423 /// page.navigate("https://example.com", WaitUntil::DomContentLoaded, Duration::from_secs(30)).await?;
424 /// if let Some(code) = page.status_code()? {
425 /// println!("HTTP {code}");
426 /// }
427 /// # Ok(())
428 /// # }
429 /// ```
430 pub fn status_code(&self) -> Result<Option<u16>> {
431 let code = self.last_status_code.load(Ordering::Acquire);
432 Ok(if code == 0 { None } else { Some(code) })
433 }
434
435 /// Return the page's `<title>` text.
436 ///
437 /// # Errors
438 ///
439 /// Returns [`BrowserError::ScriptExecutionFailed`] if the evaluation fails.
440 pub async fn title(&self) -> Result<String> {
441 timeout(self.cdp_timeout, self.page.get_title())
442 .await
443 .map_err(|_| BrowserError::Timeout {
444 operation: "get_title".to_string(),
445 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
446 })?
447 .map_err(|e| BrowserError::ScriptExecutionFailed {
448 script: "document.title".to_string(),
449 reason: e.to_string(),
450 })
451 .map(Option::unwrap_or_default)
452 }
453
454 /// Return the page's full outer HTML.
455 ///
456 /// # Errors
457 ///
458 /// Returns [`BrowserError::ScriptExecutionFailed`] if the evaluation fails.
459 pub async fn content(&self) -> Result<String> {
460 timeout(self.cdp_timeout, self.page.content())
461 .await
462 .map_err(|_| BrowserError::Timeout {
463 operation: "page.content".to_string(),
464 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
465 })?
466 .map_err(|e| BrowserError::ScriptExecutionFailed {
467 script: "document.documentElement.outerHTML".to_string(),
468 reason: e.to_string(),
469 })
470 }
471
472 /// Evaluate arbitrary JavaScript and return the result as `T`.
473 ///
474 /// # Errors
475 ///
476 /// Returns [`BrowserError::ScriptExecutionFailed`] on eval failure or
477 /// deserialization error.
478 pub async fn eval<T: serde::de::DeserializeOwned>(&self, script: &str) -> Result<T> {
479 let script_owned = script.to_string();
480 timeout(self.cdp_timeout, self.page.evaluate(script))
481 .await
482 .map_err(|_| BrowserError::Timeout {
483 operation: "page.evaluate".to_string(),
484 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
485 })?
486 .map_err(|e| BrowserError::ScriptExecutionFailed {
487 script: script_owned.clone(),
488 reason: e.to_string(),
489 })?
490 .into_value::<T>()
491 .map_err(|e| BrowserError::ScriptExecutionFailed {
492 script: script_owned,
493 reason: e.to_string(),
494 })
495 }
496
497 /// Save all cookies for the current page's origin.
498 ///
499 /// # Errors
500 ///
501 /// Returns [`BrowserError::CdpError`] if the CDP call fails.
502 pub async fn save_cookies(
503 &self,
504 ) -> Result<Vec<chromiumoxide::cdp::browser_protocol::network::Cookie>> {
505 use chromiumoxide::cdp::browser_protocol::network::GetCookiesParams;
506
507 let url = self
508 .page
509 .url()
510 .await
511 .map_err(|e| BrowserError::CdpError {
512 operation: "page.url".to_string(),
513 message: e.to_string(),
514 })?
515 .unwrap_or_default();
516
517 timeout(
518 self.cdp_timeout,
519 self.page
520 .execute(GetCookiesParams::builder().urls(vec![url]).build()),
521 )
522 .await
523 .map_err(|_| BrowserError::Timeout {
524 operation: "Network.getCookies".to_string(),
525 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
526 })?
527 .map_err(|e| BrowserError::CdpError {
528 operation: "Network.getCookies".to_string(),
529 message: e.to_string(),
530 })
531 .map(|r| r.cookies.clone())
532 }
533
534 /// Capture a screenshot of the current page as PNG bytes.
535 ///
536 /// The screenshot is full-page by default (viewport clipped to the rendered
537 /// layout area). Save the returned bytes to a `.png` file or process
538 /// them in-memory.
539 ///
540 /// # Errors
541 ///
542 /// Returns [`BrowserError::CdpError`] if the CDP `Page.captureScreenshot`
543 /// command fails, or [`BrowserError::Timeout`] if it exceeds
544 /// `cdp_timeout`.
545 ///
546 /// # Example
547 ///
548 /// ```no_run
549 /// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
550 /// use std::{time::Duration, fs};
551 ///
552 /// # async fn run() -> stygian_browser::error::Result<()> {
553 /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
554 /// let handle = pool.acquire().await?;
555 /// let mut page = handle.browser().expect("valid browser").new_page().await?;
556 /// page.navigate("https://example.com", WaitUntil::Selector("body".to_string()), Duration::from_secs(30)).await?;
557 /// let png = page.screenshot().await?;
558 /// fs::write("screenshot.png", &png).unwrap();
559 /// # Ok(())
560 /// # }
561 /// ```
562 pub async fn screenshot(&self) -> Result<Vec<u8>> {
563 use chromiumoxide::page::ScreenshotParams;
564
565 let params = ScreenshotParams::builder().full_page(true).build();
566
567 timeout(self.cdp_timeout, self.page.screenshot(params))
568 .await
569 .map_err(|_| BrowserError::Timeout {
570 operation: "Page.captureScreenshot".to_string(),
571 duration_ms: u64::try_from(self.cdp_timeout.as_millis()).unwrap_or(u64::MAX),
572 })?
573 .map_err(|e| BrowserError::CdpError {
574 operation: "Page.captureScreenshot".to_string(),
575 message: e.to_string(),
576 })
577 }
578
579 /// Borrow the underlying chromiumoxide [`Page`].
580 pub const fn inner(&self) -> &Page {
581 &self.page
582 }
583
584 /// Close this page (tab).
585 ///
586 /// Called automatically on drop; explicit call avoids suppressing the error.
587 pub async fn close(self) -> Result<()> {
588 timeout(Duration::from_secs(5), self.page.clone().close())
589 .await
590 .map_err(|_| BrowserError::Timeout {
591 operation: "page.close".to_string(),
592 duration_ms: 5000,
593 })?
594 .map_err(|e| BrowserError::CdpError {
595 operation: "page.close".to_string(),
596 message: e.to_string(),
597 })
598 }
599}
600
601impl Drop for PageHandle {
602 fn drop(&mut self) {
603 warn!("PageHandle dropped without explicit close(); spawning cleanup task");
604 // chromiumoxide Page does not implement close on Drop, so we spawn
605 // a fire-and-forget task. The page ref is already owned; we need to
606 // swap it out. We clone the Page handle (it's Arc-backed internally).
607 let page = self.page.clone();
608 tokio::spawn(async move {
609 let _ = page.close().await;
610 });
611 }
612}
613
614// ─── Tests ────────────────────────────────────────────────────────────────────
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn resource_filter_block_media_blocks_image() {
622 let filter = ResourceFilter::block_media();
623 assert!(filter.should_block("Image"));
624 assert!(filter.should_block("Font"));
625 assert!(filter.should_block("Stylesheet"));
626 assert!(filter.should_block("Media"));
627 assert!(!filter.should_block("Script"));
628 assert!(!filter.should_block("XHR"));
629 }
630
631 #[test]
632 fn resource_filter_case_insensitive() {
633 let filter = ResourceFilter::block_images_and_fonts();
634 assert!(filter.should_block("image")); // lowercase
635 assert!(filter.should_block("IMAGE")); // uppercase
636 assert!(!filter.should_block("Stylesheet"));
637 }
638
639 #[test]
640 fn resource_filter_builder_chain() {
641 let filter = ResourceFilter::default()
642 .block(ResourceType::Image)
643 .block(ResourceType::Font);
644 assert!(filter.should_block("Image"));
645 assert!(filter.should_block("Font"));
646 assert!(!filter.should_block("Stylesheet"));
647 }
648
649 #[test]
650 fn resource_filter_dedup_block() {
651 let filter = ResourceFilter::default()
652 .block(ResourceType::Image)
653 .block(ResourceType::Image); // duplicate
654 assert_eq!(filter.blocked.len(), 1);
655 }
656
657 #[test]
658 fn resource_filter_is_empty_when_default() {
659 assert!(ResourceFilter::default().is_empty());
660 assert!(!ResourceFilter::block_media().is_empty());
661 }
662
663 #[test]
664 fn wait_until_selector_stores_string() {
665 let w = WaitUntil::Selector("#foo".to_string());
666 assert!(matches!(w, WaitUntil::Selector(ref s) if s == "#foo"));
667 }
668
669 #[test]
670 fn resource_type_cdp_str() {
671 assert_eq!(ResourceType::Image.as_cdp_str(), "Image");
672 assert_eq!(ResourceType::Font.as_cdp_str(), "Font");
673 assert_eq!(ResourceType::Stylesheet.as_cdp_str(), "Stylesheet");
674 assert_eq!(ResourceType::Media.as_cdp_str(), "Media");
675 }
676
677 /// `PageHandle` must be `Send + Sync` for use across thread boundaries.
678 #[test]
679 fn page_handle_is_send_sync() {
680 fn assert_send<T: Send>() {}
681 fn assert_sync<T: Sync>() {}
682 assert_send::<PageHandle>();
683 assert_sync::<PageHandle>();
684 }
685
686 /// The status-code sentinel (0 = "not yet captured") and the conversion to
687 /// `Option<u16>` are pure-logic invariants testable without a live browser.
688 #[test]
689 fn status_code_sentinel_zero_maps_to_none() {
690 use std::sync::atomic::{AtomicU16, Ordering};
691 let atom = AtomicU16::new(0);
692 let code = atom.load(Ordering::Acquire);
693 assert_eq!(if code == 0 { None } else { Some(code) }, None::<u16>);
694 }
695
696 #[test]
697 fn status_code_non_zero_maps_to_some() {
698 use std::sync::atomic::{AtomicU16, Ordering};
699 for &expected in &[200u16, 301, 404, 503] {
700 let atom = AtomicU16::new(expected);
701 let code = atom.load(Ordering::Acquire);
702 assert_eq!(if code == 0 { None } else { Some(code) }, Some(expected));
703 }
704 }
705}