Skip to main content

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}