Skip to main content

stygian_browser/coherence/
probes.rs

1//! JavaScript probe definitions for cross-context identity surfaces.
2//!
3//! Each probe is a self-contained JavaScript expression that
4//! evaluates to a JSON string matching the [`IdentitySurface`]
5//! schema (or a `{"skipped":"..."}` envelope when the context is
6//! unavailable).
7//!
8//! All scripts:
9//!
10//! - Use old-style `function ()` / `var` rather than arrow
11//!   functions or `let`/`const` for broadest browser-engine
12//!   compatibility.
13//! - Wrap reads in `try/catch` so a single missing field never
14//!   aborts the whole probe.
15//! - Never panic; a context that cannot be probed returns
16//!   `{"skipped":"<reason>"}` which [`super::ContextObservation`]
17//!   decodes into a [`Skipped`][super::ContextObservation::Skipped]
18//!   marker.
19//!
20//! ## Worker probe synchronous-busy-wait rationale
21//!
22//! The worker probe posts a message to a `Worker` constructed from
23//! a `Blob` URL and then busy-waits on the main thread until the
24//! worker posts back its identity surface (or the deadline
25//! elapses). The busy-wait is bounded to a few hundred milliseconds
26//! — workers always reply in microseconds on a clean browser, and
27//! the timeout ensures the runner can never block indefinitely.
28//!
29//! ## Iframe probe srcdoc rationale
30//!
31//! The iframe probe creates a same-origin `<iframe>` with a
32//! `srcdoc` payload. `srcdoc` iframes inherit the parent's origin
33//! and provide a deterministic, network-free context for probing.
34
35use serde::{Deserialize, Serialize};
36
37use crate::error::{BrowserError, Result};
38use crate::page::PageHandle;
39
40use super::report::{ContextObservation, IdentitySurface};
41
42// ─── Probed JSON shape ────────────────────────────────────────────────────────
43
44/// Wire format for a probe result: an [`IdentitySurface`] flattened
45/// with an optional `skipped` discriminator field.
46///
47/// The probe script returns either:
48///
49/// - `{"skipped": "<reason>"}` — probe could not run; the reason
50///   decodes to a [`Skipped`][ContextObservation::Skipped] marker.
51/// - `{ "user_agent": "...", ... }` — a (possibly partial)
52///   [`IdentitySurface`] that decodes to an
53///   [`Observed`][ContextObservation::Observed] marker.
54///
55/// The two cases are distinguished by the presence of a
56/// `skipped` field. Using a wire wrapper struct (instead of an
57/// `#[serde(untagged)]` enum) keeps the discriminator explicit and
58/// avoids the silent-unknown-field behaviour of untagged enums.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
60struct ProbeOutput {
61    /// `Some(reason)` when the probe could not run.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    skipped: Option<String>,
64    /// Flattened identity surface. `Default::default()` when the
65    /// probe returned only a `skipped` marker.
66    #[serde(default, flatten)]
67    surface: IdentitySurface,
68}
69
70impl ProbeOutput {
71    fn into_observation(self) -> ContextObservation {
72        match self.skipped {
73            Some(reason) => ContextObservation::skipped(reason),
74            None => ContextObservation::observed(self.surface),
75        }
76    }
77}
78
79// ─── JavaScript probe scripts ─────────────────────────────────────────────────
80
81const TOP_LEVEL_PROBE: &str = concat!(
82    "(function(){",
83    "var r={};",
84    "try{r.userAgent=String(navigator.userAgent||'');}catch(e){r.userAgent='';}",
85    "try{r.platform=String(navigator.platform||'');}catch(e){r.platform='';}",
86    "try{r.languages=Array.isArray(navigator.languages)?navigator.languages.join(','):'';}catch(e){r.languages='';}",
87    "try{r.hardware_concurrency=typeof navigator.hardwareConcurrency==='number'?navigator.hardwareConcurrency:null;}catch(e){r.hardware_concurrency=null;}",
88    "try{r.device_memory=typeof navigator.deviceMemory==='number'?navigator.deviceMemory:null;}catch(e){r.device_memory=null;}",
89    "try{r.timezone=String(Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(e){r.timezone='';}",
90    "try{r.screen_width=typeof screen==='object'&&screen&&typeof screen.width==='number'?screen.width:null;}catch(e){r.screen_width=null;}",
91    "try{r.screen_height=typeof screen==='object'&&screen&&typeof screen.height==='number'?screen.height:null;}catch(e){r.screen_height=null;}",
92    "try{r.color_depth=typeof screen==='object'&&screen&&typeof screen.colorDepth==='number'?screen.colorDepth:null;}catch(e){r.color_depth=null;}",
93    "try{r.webdriver=typeof navigator.webdriver==='boolean'?navigator.webdriver:null;}catch(e){r.webdriver=null;}",
94    "return JSON.stringify(r);",
95    "})()"
96);
97
98const IFRAME_PROBE: &str = concat!(
99    "(function(){",
100    "try{",
101    "var doc=document;",
102    "var body=doc.body||doc.documentElement;",
103    "if(!body){return JSON.stringify({skipped:'no document body'});}",
104    "var f=doc.createElement('iframe');",
105    "f.setAttribute('aria-hidden','true');",
106    "f.style.cssText='position:absolute;width:0;height:0;border:0;visibility:hidden;';",
107    "f.srcdoc='<!doctype html><html><head></head><body></body></html>';",
108    "body.appendChild(f);",
109    "var w=f.contentWindow;",
110    "if(!w){try{body.removeChild(f);}catch(_e){} return JSON.stringify({skipped:'iframe contentWindow unavailable'});}",
111    "var n=w.navigator;",
112    "if(!n){try{body.removeChild(f);}catch(_e){} return JSON.stringify({skipped:'iframe navigator unavailable'});}",
113    "var r={};",
114    "try{r.user_agent=String(n.userAgent||'');}catch(e){r.user_agent='';}",
115    "try{r.platform=String(n.platform||'');}catch(e){r.platform='';}",
116    "try{r.languages=Array.isArray(n.languages)?n.languages.join(','):'';}catch(e){r.languages='';}",
117    "try{r.hardware_concurrency=typeof n.hardwareConcurrency==='number'?n.hardwareConcurrency:null;}catch(e){r.hardware_concurrency=null;}",
118    "try{r.device_memory=typeof n.deviceMemory==='number'?n.deviceMemory:null;}catch(e){r.device_memory=null;}",
119    "try{r.timezone=String(w.Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(e){r.timezone='';}",
120    "try{r.screen_width=w.screen&&typeof w.screen.width==='number'?w.screen.width:null;}catch(e){r.screen_width=null;}",
121    "try{r.screen_height=w.screen&&typeof w.screen.height==='number'?w.screen.height:null;}catch(e){r.screen_height=null;}",
122    "try{r.color_depth=w.screen&&typeof w.screen.colorDepth==='number'?w.screen.colorDepth:null;}catch(e){r.color_depth=null;}",
123    "try{r.webdriver=typeof n.webdriver==='boolean'?n.webdriver:null;}catch(e){r.webdriver=null;}",
124    "try{body.removeChild(f);}catch(_e){}",
125    "return JSON.stringify(r);",
126    "}catch(e){",
127    "return JSON.stringify({skipped:'iframe probe failed: '+(e&&e.message?e.message:String(e))});",
128    "}",
129    "})()"
130);
131
132// The worker probe embeds a multi-line JS program as a string
133// passed to `new Blob([...])`. The script is a JS string literal in
134// the host probe; the Rust raw string below contains that JS string
135// verbatim (with `\n` → newline in JS, because the entire raw
136// string is the JS source).
137const WORKER_PROBE: &str = r#"(function(){
138try{
139if(typeof Worker==='undefined'){return JSON.stringify({skipped:'Worker unsupported'});}
140if(typeof Blob==='undefined'||typeof URL==='undefined'||typeof URL.createObjectURL!=='function'){return JSON.stringify({skipped:'Blob/URL.createObjectURL unsupported'});}
141var src="self.onmessage=function(_e){var r={};try{r.user_agent=String(navigator.userAgent||'');}catch(_err){r.user_agent='';}try{r.platform=String(navigator.platform||'');}catch(_err){r.platform='';}try{r.languages=Array.isArray(navigator.languages)?navigator.languages.join(','):'';}catch(_err){r.languages='';}try{r.hardware_concurrency=typeof navigator.hardwareConcurrency==='number'?navigator.hardwareConcurrency:null;}catch(_err){r.hardware_concurrency=null;}try{r.device_memory=typeof navigator.deviceMemory==='number'?navigator.deviceMemory:null;}catch(_err){r.device_memory=null;}try{r.timezone=String(Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(_err){r.timezone='';}try{r.webdriver=typeof navigator.webdriver==='boolean'?navigator.webdriver:null;}catch(_err){r.webdriver=null;}try{self.postMessage(JSON.stringify(r));self.close();}catch(_err){self.postMessage('SKIP:'+(_err&&_err.message?_err.message:String(_err)));self.close();}};";
142var blob;
143try{blob=new Blob([src],{type:'application/javascript'});}catch(e){return JSON.stringify({skipped:'Blob construction failed: '+(e&&e.message?e.message:String(e))});}
144var url;
145try{url=URL.createObjectURL(blob);}catch(e){return JSON.stringify({skipped:'createObjectURL failed: '+(e&&e.message?e.message:String(e))});}
146var worker;
147try{worker=new Worker(url);}catch(e){try{URL.revokeObjectURL(url);}catch(_e){} return JSON.stringify({skipped:'new Worker failed: '+(e&&e.message?e.message:String(e))});}
148var result=null;
149var errorReason='';
150worker.onmessage=function(ev){result=ev&&typeof ev.data==='string'?ev.data:null;};
151worker.onerror=function(ev){errorReason=(ev&&ev.message)?ev.message:'unknown worker error';};
152try{worker.postMessage('probe');}catch(e){try{worker.terminate();}catch(_e){} try{URL.revokeObjectURL(url);}catch(_e){} return JSON.stringify({skipped:'postMessage failed: '+(e&&e.message?e.message:String(e))});}
153var deadline=Date.now()+2000;
154while(result===null&&errorReason===''&&Date.now()<deadline){}
155try{worker.terminate();}catch(_e){}
156try{URL.revokeObjectURL(url);}catch(_e){}
157if(errorReason!==''){return JSON.stringify({skipped:'worker error: '+errorReason});}
158if(result===null){return JSON.stringify({skipped:'worker probe timeout'});}
159if(typeof result==='string'&&result.indexOf('SKIP:')===0){return JSON.stringify({skipped:result.substring(5)});}
160return result;
161}catch(e){
162return JSON.stringify({skipped:'worker probe failed: '+(e&&e.message?e.message:String(e))});
163}
164})()"#;
165
166// ─── CoherenceProbe runner ────────────────────────────────────────────────────
167
168/// Runner that executes the cross-context identity probes via CDP and
169/// aggregates the results into a [`CoherenceDriftReport`][super::CoherenceDriftReport].
170///
171/// `CoherenceProbe` is the **default-on** entry point for cross-context
172/// stealth coherence. Worker probes are best-effort: when the runtime
173/// does not expose `Worker`/`Blob`/`URL.createObjectURL`, the worker
174/// slot in the report is populated with
175/// [`ContextObservation::Skipped`] rather than panicking.
176///
177/// # Idempotence
178///
179/// All probe methods are safely re-runnable on the same page; they
180/// do not mutate the DOM beyond a transient iframe that is removed
181/// at the end of the probe, and the worker is `terminate()`-ed and
182/// its `Blob` URL is `revokeObjectURL`-ed before the probe returns.
183///
184/// # Feature flag
185///
186/// The coherence module is **default-on**; no feature gate is
187/// required. The probe runner requires a live browser page (i.e.
188/// the existing `browser-cdp` capability, which is the
189/// `stygian-browser` default).
190///
191/// # Example
192///
193/// ```no_run
194/// # async fn run() -> stygian_browser::error::Result<()> {
195/// use stygian_browser::{BrowserPool, BrowserConfig, WaitUntil};
196/// use stygian_browser::coherence::CoherenceProbe;
197/// use std::time::Duration;
198///
199/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
200/// let handle = pool.acquire().await?;
201/// let mut page = handle
202///     .browser()
203///     .expect("valid browser")
204///     .new_page()
205///     .await?;
206/// page.navigate(
207///     "https://example.com",
208///     WaitUntil::DomContentLoaded,
209///     Duration::from_secs(30),
210/// )
211/// .await?;
212///
213/// let probe = CoherenceProbe::default();
214/// let report = probe.run(&page).await?;
215/// println!(
216///     "coherent={} hard_drift={} contexts={}/3",
217///     report.is_coherent(),
218///     report.has_hard_drift(),
219///     report.observed_context_count(),
220/// );
221/// # Ok(())
222/// # }
223/// ```
224#[derive(Debug, Clone, Default)]
225pub struct CoherenceProbe {
226    _private: (),
227}
228
229impl CoherenceProbe {
230    /// Build a new runner with default settings.
231    #[must_use]
232    pub const fn new() -> Self {
233        Self { _private: () }
234    }
235
236    /// Run all three probes (top, iframe, worker) and build a
237    /// [`CoherenceDriftReport`][super::CoherenceDriftReport].
238    ///
239    /// Per-context probe failures never abort the run; the failing
240    /// context is recorded as a [`Skipped`][ContextObservation::Skipped]
241    /// marker.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`BrowserError`] only when CDP itself is unreachable
246    /// (no live browser connection). Per-probe script errors are
247    /// captured as skipped observations.
248    pub async fn run(&self, page: &PageHandle) -> Result<super::CoherenceDriftReport> {
249        let top = self.probe_top(page).await;
250        let iframe = self.probe_iframe(page).await;
251        let worker = self.probe_worker(page).await;
252        Ok(super::report::build_report(top, iframe, worker, None))
253    }
254
255    /// Run all three probes **and** evaluate a
256    /// [`crate::freshness::FreshnessContract`] against the
257    /// top-level identity signature.
258    ///
259    /// The contract is checked against the top-level
260    /// [`IdentitySurface`] signature; the resulting
261    /// [`crate::freshness::FreshnessReport`] is attached to the
262    /// returned drift report under [`super::CoherenceDriftReport::freshness`].
263    ///
264    /// # Errors
265    ///
266    /// See [`Self::run`].
267    pub async fn run_with_freshness(
268        &self,
269        page: &PageHandle,
270        contract: &crate::freshness::FreshnessContract,
271    ) -> Result<super::CoherenceDriftReport> {
272        let top = self.probe_top(page).await;
273        let iframe = self.probe_iframe(page).await;
274        let worker = self.probe_worker(page).await;
275
276        // Build the freshness check input from the top-level surface
277        // signature, when available.
278        let freshness_report = match &top {
279            ContextObservation::Observed { surface } => {
280                let observed_signature = super::report::surface_signature(surface);
281                let input = crate::freshness::FreshnessCheckInput::new(
282                    &contract.domain,
283                    Some(observed_signature.as_str()),
284                    crate::freshness::unix_epoch_ms(),
285                );
286                let report = crate::freshness::FreshnessReport::evaluate(contract, &input);
287                report.log();
288                Some(report)
289            }
290            ContextObservation::Skipped { .. } => None,
291        };
292
293        Ok(super::report::build_report(
294            top,
295            iframe,
296            worker,
297            freshness_report,
298        ))
299    }
300
301    /// Run only the top-level document probe.
302    ///
303    /// Useful for unit tests or callers that want a single-context
304    /// snapshot without paying for the iframe + worker probes.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`BrowserError`] on CDP failure. Script errors
309    /// surface as a [`Skipped`][ContextObservation::Skipped]
310    /// observation.
311    pub async fn probe_top(&self, page: &PageHandle) -> ContextObservation {
312        run_probe(page, TOP_LEVEL_PROBE, "top-level").await
313    }
314
315    /// Run only the same-origin iframe probe.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`BrowserError`] on CDP failure. Script errors
320    /// surface as a [`Skipped`][ContextObservation::Skipped]
321    /// observation.
322    pub async fn probe_iframe(&self, page: &PageHandle) -> ContextObservation {
323        run_probe(page, IFRAME_PROBE, "iframe").await
324    }
325
326    /// Run only the dedicated/shared worker probe.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`BrowserError`] on CDP failure. Script errors
331    /// surface as a [`Skipped`][ContextObservation::Skipped]
332    /// observation.
333    pub async fn probe_worker(&self, page: &PageHandle) -> ContextObservation {
334        run_probe(page, WORKER_PROBE, "worker").await
335    }
336}
337
338// ─── Internal: run a single probe and decode the JSON envelope ────────────────
339
340async fn run_probe(page: &PageHandle, script: &str, label: &'static str) -> ContextObservation {
341    let json: String = match page.eval(script).await {
342        Ok(s) => s,
343        Err(err) => {
344            tracing::warn!(
345                context = label,
346                error = %err,
347                "coherence probe CDP evaluation failed",
348            );
349            return ContextObservation::skipped(format!("{label} CDP failure: {err}"));
350        }
351    };
352    match serde_json::from_str::<ProbeOutput>(&json) {
353        Ok(out) => out.into_observation(),
354        Err(err) => {
355            tracing::warn!(
356                context = label,
357                error = %err,
358                raw = %json,
359                "coherence probe returned invalid JSON",
360            );
361            ContextObservation::skipped(format!("{label} JSON decode failed: {err}"))
362        }
363    }
364}
365
366// Compile-time guarantee that the public error type accepts our
367// `Result` alias; silences dead-code lints on `BrowserError` in
368// feature combinations where the import would otherwise be unused.
369#[allow(dead_code)]
370const _: fn() -> Option<BrowserError> = || None;
371
372// ─── Tests ────────────────────────────────────────────────────────────────────
373
374#[cfg(test)]
375#[allow(
376    clippy::unwrap_used,
377    clippy::expect_used,
378    clippy::panic,
379    clippy::indexing_slicing
380)]
381mod tests {
382    use super::*;
383    use crate::coherence::report::{ContextKind, ContextPair, DriftSeverity, build_report};
384
385    #[test]
386    fn top_level_probe_uses_old_style_function_and_json_stringify() {
387        // Old-style IIFE + var for broadest browser-engine compatibility
388        assert!(TOP_LEVEL_PROBE.starts_with("(function(){"));
389        assert!(TOP_LEVEL_PROBE.contains("var r={};"));
390        assert!(TOP_LEVEL_PROBE.contains("JSON.stringify(r)"));
391        assert!(!TOP_LEVEL_PROBE.contains("=>"), "no arrow functions");
392        assert!(!TOP_LEVEL_PROBE.contains("let "), "no `let` declarations");
393        assert!(
394            !TOP_LEVEL_PROBE.contains("const "),
395            "no `const` declarations"
396        );
397    }
398
399    #[test]
400    fn iframe_probe_creates_srcdoc_iframe() {
401        assert!(IFRAME_PROBE.contains("srcdoc="));
402        assert!(IFRAME_PROBE.contains("createElement('iframe')"));
403        assert!(IFRAME_PROBE.contains("appendChild"));
404        // Cleanup is required for idempotence
405        assert!(IFRAME_PROBE.contains("removeChild"));
406    }
407
408    #[test]
409    fn worker_probe_uses_blob_url_and_terminates() {
410        assert!(WORKER_PROBE.contains("new Worker("));
411        assert!(WORKER_PROBE.contains("createObjectURL"));
412        assert!(WORKER_PROBE.contains("terminate"));
413        assert!(WORKER_PROBE.contains("revokeObjectURL"));
414        // Bounded busy-wait so the runner can never block indefinitely
415        assert!(WORKER_PROBE.contains("Date.now()+2000"));
416    }
417
418    #[test]
419    fn all_probes_wrap_in_iife() {
420        for (name, script) in [
421            ("top", TOP_LEVEL_PROBE),
422            ("iframe", IFRAME_PROBE),
423            ("worker", WORKER_PROBE),
424        ] {
425            let trimmed = script.trim_start();
426            assert!(
427                trimmed.starts_with("(function(){"),
428                "{name} probe must be a self-invoking function expression"
429            );
430            assert!(
431                script.trim_end().ends_with(")()"),
432                "{name} probe must end with the IIFE invocation"
433            );
434        }
435    }
436
437    #[test]
438    fn probe_output_skipped_decodes_to_observation() {
439        let raw = r#"{"skipped":"Worker unsupported"}"#;
440        let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
441        let obs = parsed.into_observation();
442        assert!(obs.is_skipped());
443        assert!(!obs.is_observed());
444    }
445
446    #[test]
447    fn probe_output_observed_decodes_to_observation() {
448        let raw = r#"{"user_agent":"Mozilla/5.0","platform":"MacIntel","languages":"en-US","hardware_concurrency":8,"device_memory":8,"timezone":"UTC","screen_width":1920,"screen_height":1080,"color_depth":24,"webdriver":false}"#;
449        let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
450        let obs = parsed.into_observation();
451        let surface = obs.surface().expect("surface present");
452        assert_eq!(surface.user_agent.as_deref(), Some("Mozilla/5.0"));
453        assert_eq!(surface.hardware_concurrency, Some(8));
454        assert_eq!(surface.webdriver, Some(false));
455    }
456
457    #[test]
458    fn probe_output_partial_surface_decodes_cleanly() {
459        // Missing fields are allowed; the deserialiser fills them with None.
460        let raw = r#"{"user_agent":"Mozilla/5.0"}"#;
461        let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
462        let surface = parsed
463            .into_observation()
464            .surface()
465            .expect("surface")
466            .clone();
467        assert_eq!(surface.user_agent.as_deref(), Some("Mozilla/5.0"));
468        assert!(surface.platform.is_none());
469        assert!(surface.webdriver.is_none());
470    }
471
472    #[test]
473    fn probe_output_invalid_falls_back_to_observed_with_empty_surface() {
474        // A bare `null` is not a valid struct; the runner treats
475        // JSON decode failures as a `Skipped` marker (see
476        // `run_probe`), not a panicking decode error.
477        let raw = "null";
478        let parsed: serde_json::Result<ProbeOutput> = serde_json::from_str(raw);
479        assert!(parsed.is_err());
480
481        // An empty `{}` IS a valid struct — it decodes to a default
482        // (empty) `IdentitySurface` with no `skipped` marker, which
483        // is the contract for "context probed but produced nothing".
484        let parsed: ProbeOutput = serde_json::from_str("{}").expect("decode empty object");
485        let obs = parsed.into_observation();
486        assert!(obs.is_observed());
487        assert!(obs.surface().expect("surface").is_empty());
488    }
489
490    #[test]
491    fn build_report_with_three_skipped_contexts_emits_no_drift() {
492        let report = build_report(
493            ContextObservation::skipped("a"),
494            ContextObservation::skipped("b"),
495            ContextObservation::skipped("c"),
496            None,
497        );
498        assert!(report.is_coherent());
499        assert_eq!(report.observed_context_count(), 0);
500        assert_eq!(report.skipped_context_count(), 3);
501    }
502
503    #[test]
504    fn coherence_probe_default_is_constructible() {
505        let _ = CoherenceProbe::default();
506        let _ = CoherenceProbe::new();
507    }
508
509    #[test]
510    fn drift_severity_classifies_hard_fields() {
511        assert_eq!(
512            super::super::report::field_severity("user_agent"),
513            DriftSeverity::Hard
514        );
515        assert_eq!(
516            super::super::report::field_severity("platform"),
517            DriftSeverity::Hard
518        );
519        assert_eq!(
520            super::super::report::field_severity("languages"),
521            DriftSeverity::Hard
522        );
523        assert_eq!(
524            super::super::report::field_severity("webdriver"),
525            DriftSeverity::Hard
526        );
527        assert_eq!(
528            super::super::report::field_severity("hardware_concurrency"),
529            DriftSeverity::KnownLimitation
530        );
531        assert_eq!(
532            super::super::report::field_severity("device_memory"),
533            DriftSeverity::KnownLimitation
534        );
535    }
536
537    #[test]
538    fn context_kind_constants_resolve_internally() {
539        // Sanity-check the sides() resolver used by the comparison
540        // helpers; this catches typos in the ContextPair → ContextKind
541        // mapping before they reach a live browser.
542        assert_eq!(
543            ContextPair::TopIframe.sides(),
544            (ContextKind::Top, ContextKind::Iframe)
545        );
546        assert_eq!(
547            ContextPair::TopWorker.sides(),
548            (ContextKind::Top, ContextKind::Worker)
549        );
550        assert_eq!(
551            ContextPair::IframeWorker.sides(),
552            (ContextKind::Iframe, ContextKind::Worker)
553        );
554    }
555}