Skip to main content

browser_automation_cli/native/
network.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Network interception, request logs, and console error tracking.
3//!
4//! # Workload
5//!
6//! **I/O-bound** CDP. Multi-page `about:blank` sanitize fans out with
7//! [`crate::concurrency::join_bounded`]. Domain allow-lists are small and
8//! sequential (cost ≪ overhead).
9#![allow(missing_docs)]
10use serde_json::{json, Value};
11use rustc_hash::FxHashMap;
12
13use super::cdp::client::CdpClient;
14
15pub async fn set_extra_headers(
16    client: &CdpClient,
17    session_id: &str,
18    headers: &FxHashMap<String, String>,
19) -> Result<(), String> {
20    let headers_value: Value = headers
21        .iter()
22        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
23        .collect::<serde_json::Map<String, Value>>()
24        .into();
25
26    client
27        .send_command(
28            "Network.setExtraHTTPHeaders",
29            Some(json!({ "headers": headers_value })),
30            Some(session_id),
31        )
32        .await?;
33
34    Ok(())
35}
36
37pub async fn set_offline(
38    client: &CdpClient,
39    session_id: &str,
40    offline: bool,
41) -> Result<(), String> {
42    set_network_conditions(client, session_id, offline, 0.0, -1.0, -1.0).await
43}
44
45/// Activate `Network.emulateNetworkConditions` (latency ms; throughput bytes/s, -1 = unlimited).
46pub async fn set_network_conditions(
47    client: &CdpClient,
48    session_id: &str,
49    offline: bool,
50    latency_ms: f64,
51    download_throughput: f64,
52    upload_throughput: f64,
53) -> Result<(), String> {
54    client
55        .send_command(
56            "Network.emulateNetworkConditions",
57            Some(json!({
58                "offline": offline,
59                "latency": latency_ms,
60                "downloadThroughput": download_throughput,
61                "uploadThroughput": upload_throughput,
62            })),
63            Some(session_id),
64        )
65        .await?;
66    Ok(())
67}
68
69pub async fn set_cpu_throttling_rate(
70    client: &CdpClient,
71    session_id: &str,
72    rate: f64,
73) -> Result<(), String> {
74    client
75        .send_command(
76            "Emulation.setCPUThrottlingRate",
77            Some(json!({ "rate": rate })),
78            Some(session_id),
79        )
80        .await?;
81    Ok(())
82}
83
84pub async fn set_content(client: &CdpClient, session_id: &str, html: &str) -> Result<(), String> {
85    // Get current frame ID
86    let tree_result = client
87        .send_command_no_params("Page.getFrameTree", Some(session_id))
88        .await?;
89
90    let frame_id = tree_result
91        .get("frameTree")
92        .and_then(|t| t.get("frame"))
93        .and_then(|f| f.get("id"))
94        .and_then(|id| id.as_str())
95        .ok_or("Could not determine frame ID")?;
96
97    client
98        .send_command(
99            "Page.setDocumentContent",
100            Some(json!({
101                "frameId": frame_id,
102                "html": html,
103            })),
104            Some(session_id),
105        )
106        .await?;
107
108    Ok(())
109}
110
111// ---------------------------------------------------------------------------
112// Domain filter
113// ---------------------------------------------------------------------------
114
115#[derive(Debug, Clone)]
116pub struct DomainFilter {
117    pub allowed_domains: Vec<String>,
118}
119
120impl DomainFilter {
121    pub fn new(domains: &str) -> Self {
122        let allowed = parse_domain_list(domains);
123        Self {
124            allowed_domains: allowed,
125        }
126    }
127
128    pub fn is_allowed(&self, hostname: &str) -> bool {
129        if self.allowed_domains.is_empty() {
130            return true;
131        }
132        let hostname = hostname.to_lowercase();
133        for pattern in &self.allowed_domains {
134            if let Some(suffix) = pattern.strip_prefix("*.") {
135                if hostname == suffix || hostname.ends_with(&format!(".{}", suffix)) {
136                    return true;
137                }
138            } else if hostname == *pattern {
139                return true;
140            }
141        }
142        false
143    }
144
145    pub fn check_url(&self, url: &str) -> Result<(), String> {
146        if self.allowed_domains.is_empty() {
147            return Ok(());
148        }
149        let parsed = url::Url::parse(url).map_err(|_| format!("Invalid URL: {}", url))?;
150        let hostname = parsed
151            .host_str()
152            .ok_or_else(|| format!("No hostname in URL: {}", url))?;
153        if self.is_allowed(hostname) {
154            Ok(())
155        } else {
156            Err(format!(
157                "Domain '{}' is not in the allowed domains list",
158                hostname
159            ))
160        }
161    }
162}
163
164fn parse_domain_list(input: &str) -> Vec<String> {
165    input
166        .split(',')
167        .map(|s| s.trim().to_lowercase())
168        .filter(|s| !s.is_empty())
169        .collect()
170}
171
172pub async fn sanitize_existing_pages(
173    client: &CdpClient,
174    pages: &[super::browser::PageInfo],
175    filter: &DomainFilter,
176) {
177    // Multi-page navigate is I/O-bound CDP — fan-out with join_bounded (Semaphore).
178    let to_blank: Vec<&super::browser::PageInfo> = pages
179        .iter()
180        .filter(|page| {
181            if page.url.is_empty() || page.url == "about:blank" {
182                return false;
183            }
184            url::Url::parse(&page.url)
185                .ok()
186                .and_then(|u| u.host_str().map(|h| h.to_string()))
187                .is_some_and(|hostname| !filter.is_allowed(&hostname))
188        })
189        .collect();
190    if to_blank.is_empty() {
191        return;
192    }
193    let cdp_limit = crate::concurrency::effective_limit_capped(32);
194    let futs: Vec<_> = to_blank
195        .iter()
196        .map(|page| {
197            let sid = page.session_id.as_str();
198            async move {
199                let _ = client
200                    .send_command(
201                        "Page.navigate",
202                        Some(json!({ "url": "about:blank" })),
203                        Some(sid),
204                    )
205                    .await;
206            }
207        })
208        .collect();
209    let _ = crate::concurrency::join_bounded(futs, cdp_limit).await;
210}
211
212pub async fn install_domain_filter_script(
213    client: &CdpClient,
214    session_id: &str,
215    allowed_domains: &[String],
216) -> Result<(), String> {
217    if allowed_domains.is_empty() {
218        return Ok(());
219    }
220
221    let script = domain_filter_script(allowed_domains);
222
223    client
224        .send_command(
225            "Page.addScriptToEvaluateOnNewDocument",
226            Some(json!({ "source": &script })),
227            Some(session_id),
228        )
229        .await?;
230
231    install_domain_filter_runtime_script(client, session_id, allowed_domains).await?;
232
233    Ok(())
234}
235
236async fn install_domain_filter_runtime_script(
237    client: &CdpClient,
238    session_id: &str,
239    allowed_domains: &[String],
240) -> Result<(), String> {
241    if allowed_domains.is_empty() {
242        return Ok(());
243    }
244
245    let script = domain_filter_script(allowed_domains);
246    let evaluation = client
247        .send_command(
248            "Runtime.evaluate",
249            Some(json!({ "expression": &script })),
250            Some(session_id),
251        )
252        .await?;
253    if let Some(details) = evaluation.get("exceptionDetails") {
254        let message = details
255            .get("exception")
256            .and_then(|exception| exception.get("description"))
257            .and_then(Value::as_str)
258            .or_else(|| details.get("text").and_then(Value::as_str))
259            .unwrap_or("unknown JavaScript error");
260        return Err(format!(
261            "Failed to apply domain filter to the current execution context: {}",
262            message
263        ));
264    }
265
266    Ok(())
267}
268
269fn domain_filter_script(allowed_domains: &[String]) -> String {
270    let domains_json = serde_json::to_string(allowed_domains).unwrap_or("[]".to_string());
271    format!(
272        r#"(() => {{
273            const _allowed = {};
274            function _installDomainFilter(_allowed, _baseOverride) {{
275            const _global = globalThis;
276            function _securityError(message) {{
277                if (typeof DOMException === 'function') {{
278                    return new DOMException(message, 'SecurityError');
279                }}
280                const error = new Error(message);
281                error.name = 'SecurityError';
282                return error;
283            }}
284            function _isDomainAllowed(hostname) {{
285                hostname = hostname.toLowerCase();
286                for (const p of _allowed) {{
287                    if (p.startsWith('*.')) {{
288                        const suffix = p.slice(2);
289                        if (hostname === suffix || hostname.endsWith('.' + suffix)) return true;
290                    }} else if (hostname === p) return true;
291                }}
292                return false;
293            }}
294            const _baseHref = _baseOverride || (_global.location && _global.location.href ? _global.location.href : 'about:blank');
295            function _checkedUrl(url, apiName) {{
296                const u = new URL(url, _baseHref);
297                if (['http:', 'https:', 'ws:', 'wss:'].includes(u.protocol) && !_isDomainAllowed(u.hostname)) {{
298                    throw _securityError(apiName + ' blocked: ' + u.hostname);
299                }}
300                return u.href;
301            }}
302            function _assertAllowedUrl(url, apiName) {{
303                _checkedUrl(url, apiName);
304            }}
305            function _checkedWebSocketUrl(url, apiName) {{
306                const u = new URL(url, _baseHref);
307                if (u.protocol === 'http:') u.protocol = 'ws:';
308                if (u.protocol === 'https:') u.protocol = 'wss:';
309                if (['ws:', 'wss:'].includes(u.protocol) && !_isDomainAllowed(u.hostname)) {{
310                    throw _securityError(apiName + ' blocked: ' + u.hostname);
311                }}
312                return u.href;
313            }}
314            function _requestUrl(input) {{
315                if (typeof input === 'string') return input;
316                if (typeof URL === 'function' && input instanceof URL) return input.href;
317                if (input && typeof input.url === 'string') return input.url;
318                return String(input);
319            }}
320            const _workerUrlCache = typeof Map === 'function' ? new Map() : null;
321            function _checkedWorkerScriptUrl(scriptURL, apiName) {{
322                let absolute;
323                try {{
324                    const u = new URL(scriptURL, _baseHref);
325                    absolute = u.href;
326                    if (u.protocol === 'blob:') {{
327                        try {{
328                            const inner = new URL(u.pathname);
329                            if (inner.hostname && !_isDomainAllowed(inner.hostname)) {{
330                                throw _securityError(apiName + ' blocked: ' + inner.hostname);
331                            }}
332                        }} catch(e) {{ if (e && e.name === 'SecurityError') throw e; }}
333                    }} else if (u.hostname && !_isDomainAllowed(u.hostname)) {{
334                        throw _securityError(apiName + ' blocked: ' + u.hostname);
335                    }}
336                }} catch(e) {{
337                    if (e && e.name === 'SecurityError') throw e;
338                    throw e;
339                }}
340                return absolute;
341            }}
342            function _workerScriptUrl(scriptURL, options, apiName) {{
343                if (!_global.Blob || !_global.URL || typeof _global.URL.createObjectURL !== 'function') {{
344                    throw _securityError(apiName + ' blocked: worker bootstrap APIs are unavailable');
345                }}
346                const absolute = _checkedWorkerScriptUrl(scriptURL, apiName);
347                const isModule = options && typeof options === 'object' && options.type === 'module';
348                const cacheKey = apiName + '|' + (isModule ? 'module' : 'classic') + '|' + absolute;
349                if (_workerUrlCache && _workerUrlCache.has(cacheKey)) return _workerUrlCache.get(cacheKey);
350                const installSource = '(' + _installDomainFilter.toString() + ')(' + JSON.stringify(_allowed) + ', ' + JSON.stringify(absolute) + ');\n';
351                const source = installSource + (isModule
352                    ? 'await import(' + JSON.stringify(absolute) + ');\n'
353                    : 'importScripts(' + JSON.stringify(absolute) + ');\n');
354                const wrapped = _global.URL.createObjectURL(new Blob([source], {{ type: 'application/javascript' }}));
355                if (_workerUrlCache) _workerUrlCache.set(cacheKey, wrapped);
356                return wrapped;
357            }}
358            function _constructWorker(OrigCtor, scriptURL, options, apiName) {{
359                const checkedUrl = _checkedWorkerScriptUrl(scriptURL, apiName);
360                try {{
361                    const bootstrapUrl = _workerScriptUrl(checkedUrl, options, apiName);
362                    const worker = new OrigCtor(bootstrapUrl, options);
363                    return worker;
364                }} catch (error) {{
365                    // Fail closed if the guarded bootstrap cannot be created.
366                    throw error;
367                }}
368            }}
369            const OrigWorker = _global.Worker;
370            if (typeof OrigWorker === 'function') {{
371                _global.Worker = function(scriptURL, options) {{
372                    return _constructWorker(OrigWorker, scriptURL, options, 'Worker');
373                }};
374                _global.Worker.prototype = OrigWorker.prototype;
375            }}
376            const OrigSharedWorker = _global.SharedWorker;
377            if (typeof OrigSharedWorker === 'function') {{
378                _global.SharedWorker = function(scriptURL, options) {{
379                    return _constructWorker(OrigSharedWorker, scriptURL, options, 'SharedWorker');
380                }};
381                _global.SharedWorker.prototype = OrigSharedWorker.prototype;
382            }}
383            const OrigImportScripts = _global.importScripts;
384            if (typeof OrigImportScripts === 'function') {{
385                _global.importScripts = function() {{
386                    const urls = Array.prototype.slice.call(arguments).map((url) => {{
387                        try {{
388                            return _checkedUrl(url, 'importScripts');
389                        }} catch(e) {{
390                            if (e && e.name === 'SecurityError') throw e;
391                            return url;
392                        }}
393                    }});
394                    return OrigImportScripts.apply(this, urls);
395                }};
396            }}
397            const OrigFetch = _global.fetch;
398            if (typeof OrigFetch === 'function') {{
399                _global.fetch = function(input, init) {{
400                    try {{
401                        if (typeof input === 'string') {{
402                            return OrigFetch.call(this, _checkedUrl(input, 'Fetch'), init);
403                        }}
404                        _assertAllowedUrl(_requestUrl(input), 'Fetch');
405                    }} catch(e) {{
406                        if (e && e.name === 'SecurityError') return Promise.reject(e);
407                    }}
408                    return OrigFetch.apply(this, arguments);
409                }};
410            }}
411            const OrigXHR = _global.XMLHttpRequest;
412            if (typeof OrigXHR === 'function' && OrigXHR.prototype && OrigXHR.prototype.open) {{
413                const origOpen = OrigXHR.prototype.open;
414                OrigXHR.prototype.open = function(method, url) {{
415                    let checkedUrl = url;
416                    try {{
417                        checkedUrl = _checkedUrl(url, 'XMLHttpRequest');
418                    }} catch(e) {{
419                        if (e && e.name === 'SecurityError') throw e;
420                    }}
421                    const args = Array.prototype.slice.call(arguments);
422                    args[1] = checkedUrl;
423                    return origOpen.apply(this, args);
424                }};
425            }}
426            const OrigWS = _global.WebSocket;
427            if (typeof OrigWS === 'function') {{
428                _global.WebSocket = function(url, protocols) {{
429                    let checkedUrl = url;
430                    try {{
431                        checkedUrl = _checkedWebSocketUrl(url, 'WebSocket');
432                    }} catch(e) {{ if (e && e.name === 'SecurityError') throw e; }}
433                    return new OrigWS(checkedUrl, protocols);
434                }};
435                _global.WebSocket.prototype = OrigWS.prototype;
436            }}
437            const OrigES = _global.EventSource;
438            if (OrigES) {{
439                _global.EventSource = function(url, opts) {{
440                    let checkedUrl = url;
441                    try {{
442                        checkedUrl = _checkedUrl(url, 'EventSource');
443                    }} catch(e) {{ if (e && e.name === 'SecurityError') throw e; }}
444                    return new OrigES(checkedUrl, opts);
445                }};
446                _global.EventSource.prototype = OrigES.prototype;
447            }}
448            const origBeacon = _global.navigator && _global.navigator.sendBeacon;
449            if (origBeacon) {{
450                _global.navigator.sendBeacon = function(url, data) {{
451                    let checkedUrl = url;
452                    try {{
453                        checkedUrl = _checkedUrl(url, 'Beacon');
454                    }} catch(e) {{ return false; }}
455                    return origBeacon.call(_global.navigator, checkedUrl, data);
456                }};
457            }}
458            function _blockPeerConnection(name) {{
459                if (typeof _global[name] !== 'function') return;
460                const BlockedPeerConnection = function() {{
461                    throw _securityError('RTCPeerConnection blocked while domain filtering is active');
462                }};
463                Object.defineProperty(BlockedPeerConnection, 'prototype', {{
464                    value: Object.freeze(Object.create(null)),
465                    writable: false
466                }});
467                try {{
468                    Object.defineProperty(_global, name, {{
469                        value: BlockedPeerConnection,
470                        writable: false,
471                        configurable: false
472                    }});
473                }} catch (_) {{
474                    _global[name] = BlockedPeerConnection;
475                }}
476            }}
477            _blockPeerConnection('RTCPeerConnection');
478            _blockPeerConnection('webkitRTCPeerConnection');
479            }}
480            _installDomainFilter(_allowed);
481        }})()"#,
482        domains_json,
483    )
484}
485
486/// Enable Fetch-based network interception for domain filtering.
487/// This intercepts all requests and checks them against the allowed domains list.
488/// The actual handling of `Fetch.requestPaused` events happens in
489/// `resolve_fetch_paused` in the actions module.
490pub async fn install_domain_filter_fetch(
491    client: &CdpClient,
492    session_id: &str,
493    handle_auth_requests: bool,
494) -> Result<(), String> {
495    let mut params = json!({
496        "patterns": [{ "urlPattern": "*" }]
497    });
498    if handle_auth_requests {
499        params["handleAuthRequests"] = json!(true);
500    }
501    client
502        .send_command("Fetch.enable", Some(params), Some(session_id))
503        .await?;
504    Ok(())
505}
506
507/// Install both layers of domain filtering on a session:
508/// 1. Fetch-based network interception
509/// 2. JS patching for APIs outside Fetch interception, including workers,
510///    WebSocket, EventSource, sendBeacon, and RTCPeerConnection.
511pub async fn install_domain_filter(
512    client: &CdpClient,
513    session_id: &str,
514    allowed_domains: &[String],
515    handle_auth_requests: bool,
516) -> Result<(), String> {
517    install_domain_filter_fetch(client, session_id, handle_auth_requests).await?;
518    install_domain_filter_script(client, session_id, allowed_domains).await?;
519    Ok(())
520}
521
522// ---------------------------------------------------------------------------
523// Console arg formatting (CDP RemoteObject → human-readable string)
524// ---------------------------------------------------------------------------
525
526/// Format a single CDP RemoteObject arg into a human-readable string.
527/// Priority: value → preview → description.
528pub fn format_console_arg(arg: &Value) -> Option<String> {
529    let obj_type = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
530    let subtype = arg.get("subtype").and_then(|v| v.as_str());
531
532    if obj_type == "undefined" {
533        return Some("undefined".to_string());
534    }
535
536    if subtype == Some("null") {
537        return Some("null".to_string());
538    }
539
540    // Primitive value
541    if let Some(v) = arg.get("value") {
542        return Some(match v {
543            Value::String(s) => s.clone(),
544            Value::Null => "null".to_string(),
545            other => other.to_string(),
546        });
547    }
548
549    // Skip preview for Map/Set — their description ("Map(1)", "Set(3)") is more useful
550    // than their preview properties (which only show "size")
551    if let Some(preview) = arg.get("preview") {
552        let preview_subtype = preview.get("subtype").and_then(|v| v.as_str());
553        if matches!(preview_subtype, Some("map" | "set" | "weakmap" | "weakset")) {
554            return arg
555                .get("description")
556                .and_then(|v| v.as_str())
557                .map(|s| s.to_string());
558        }
559        let is_array = subtype == Some("array") || preview_subtype == Some("array");
560        if let Some(props) = preview.get("properties").and_then(|v| v.as_array()) {
561            let overflow = preview
562                .get("overflow")
563                .and_then(|v| v.as_bool())
564                .unwrap_or(false);
565            let formatted_props: Vec<String> = props
566                .iter()
567                .filter_map(|p| {
568                    let value_str = p.get("value").and_then(|v| v.as_str())?;
569                    let prop_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("");
570                    let formatted_value = if prop_type == "string" {
571                        format!("\"{}\"", value_str)
572                    } else {
573                        value_str.to_string()
574                    };
575                    if is_array {
576                        Some(formatted_value)
577                    } else {
578                        let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
579                        Some(format!("{}: {}", name, formatted_value))
580                    }
581                })
582                .collect();
583
584            let inner = if overflow {
585                format!("{}, ...", formatted_props.join(", "))
586            } else {
587                formatted_props.join(", ")
588            };
589
590            return if is_array {
591                Some(format!("[{}]", inner))
592            } else {
593                Some(format!("{{{}}}", inner))
594            };
595        }
596    }
597
598    // Fallback to description
599    arg.get("description")
600        .and_then(|v| v.as_str())
601        .map(|s| s.to_string())
602}
603
604/// Format an array of CDP RemoteObject args into a single space-separated string.
605pub fn format_console_args(args: &[Value]) -> String {
606    args.iter()
607        .filter_map(format_console_arg)
608        .collect::<Vec<_>>()
609        .join(" ")
610}
611
612// ---------------------------------------------------------------------------
613// Console and error tracking
614// ---------------------------------------------------------------------------
615
616#[derive(Debug, Clone)]
617pub struct ConsoleEntry {
618    pub level: String,
619    pub text: String,
620    pub args: Vec<Value>,
621}
622
623#[derive(Debug, Clone)]
624pub struct ErrorEntry {
625    pub text: String,
626    pub url: Option<String>,
627    pub line: Option<i64>,
628    pub column: Option<i64>,
629}
630
631pub struct EventTracker {
632    pub console_entries: Vec<ConsoleEntry>,
633    pub error_entries: Vec<ErrorEntry>,
634    pub max_entries: usize,
635}
636
637impl Default for EventTracker {
638    fn default() -> Self {
639        Self::new()
640    }
641}
642
643impl EventTracker {
644    pub fn new() -> Self {
645        Self {
646            console_entries: Vec::new(),
647            error_entries: Vec::new(),
648            max_entries: 1000,
649        }
650    }
651
652    pub fn add_console(&mut self, level: &str, text: &str, args: Vec<Value>) {
653        if self.console_entries.len() >= self.max_entries {
654            self.console_entries.remove(0);
655        }
656        self.console_entries.push(ConsoleEntry {
657            level: level.to_string(),
658            text: text.to_string(),
659            args,
660        });
661    }
662
663    pub fn add_error(
664        &mut self,
665        text: &str,
666        url: Option<&str>,
667        line: Option<i64>,
668        col: Option<i64>,
669    ) {
670        if self.error_entries.len() >= self.max_entries {
671            self.error_entries.remove(0);
672        }
673        self.error_entries.push(ErrorEntry {
674            text: text.to_string(),
675            url: url.map(String::from),
676            line,
677            column: col,
678        });
679    }
680
681    pub fn clear_console(&mut self) {
682        self.console_entries.clear();
683    }
684
685    pub fn get_console_json(&self) -> Value {
686        let messages: Vec<Value> = self
687            .console_entries
688            .iter()
689            .map(|e| {
690                let mut msg = json!({ "type": e.level, "text": e.text });
691                if !e.args.is_empty() {
692                    if let Some(obj) = msg.as_object_mut() {
693                        obj.insert("args".to_string(), Value::Array(e.args.clone()));
694                    }
695                }
696                msg
697            })
698            .collect();
699        json!({ "messages": messages })
700    }
701
702    pub fn get_errors_json(&self) -> Value {
703        let entries: Vec<Value> = self
704            .error_entries
705            .iter()
706            .map(|e| {
707                json!({
708                    "text": e.text,
709                    "url": e.url,
710                    "line": e.line,
711                    "column": e.column,
712                })
713            })
714            .collect();
715        json!({ "errors": entries })
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn test_domain_filter_exact() {
725        let filter = DomainFilter::new("example.com");
726        assert!(filter.is_allowed("example.com"));
727        assert!(!filter.is_allowed("other.com"));
728    }
729
730    #[test]
731    fn test_domain_filter_wildcard() {
732        let filter = DomainFilter::new("*.example.com");
733        assert!(filter.is_allowed("example.com"));
734        assert!(filter.is_allowed("api.example.com"));
735        assert!(filter.is_allowed("sub.api.example.com"));
736        assert!(!filter.is_allowed("other.com"));
737    }
738
739    #[test]
740    fn test_domain_filter_empty() {
741        let filter = DomainFilter::new("");
742        assert!(filter.is_allowed("anything.com"));
743    }
744
745    #[test]
746    fn test_domain_filter_multiple() {
747        let filter = DomainFilter::new("example.com, *.api.io");
748        assert!(filter.is_allowed("example.com"));
749        assert!(filter.is_allowed("api.io"));
750        assert!(filter.is_allowed("v1.api.io"));
751        assert!(!filter.is_allowed("other.com"));
752    }
753
754    #[test]
755    fn test_parse_domain_list() {
756        let domains = parse_domain_list("A.com, B.com , *.C.com");
757        assert_eq!(domains, vec!["a.com", "b.com", "*.c.com"]);
758    }
759
760    #[test]
761    fn test_domain_filter_script_blocks_peer_connection_constructors() {
762        let script = domain_filter_script(&["example.com".to_string()]);
763        assert!(script.contains("_blockPeerConnection('RTCPeerConnection')"));
764        assert!(script.contains("_blockPeerConnection('webkitRTCPeerConnection')"));
765        assert!(script.contains("RTCPeerConnection blocked while domain filtering is active"));
766        assert!(script.contains("configurable: false"));
767    }
768
769    #[test]
770    fn test_domain_filter_script_fails_closed_when_worker_blob_is_csp_blocked() {
771        let script = domain_filter_script(&["example.com".to_string()]);
772        assert!(script.contains("createObjectURL(new Blob"));
773        assert!(script.contains("'await import(' + JSON.stringify(absolute)"));
774        assert!(script.contains("const worker = new OrigCtor(bootstrapUrl, options)"));
775        assert!(script.contains("return worker"));
776        assert!(!script.contains("_wrapWorkerWithCspFallback"));
777        assert!(!script.contains("return new OrigCtor(checkedUrl, options)"));
778    }
779
780    #[test]
781    fn test_event_tracker() {
782        let mut tracker = EventTracker::new();
783        tracker.add_console("log", "hello", vec![]);
784        tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
785
786        assert_eq!(tracker.console_entries.len(), 1);
787        assert_eq!(tracker.error_entries.len(), 1);
788    }
789
790    #[test]
791    fn test_console_json_includes_args() {
792        let mut tracker = EventTracker::new();
793        let raw_args = vec![
794            json!({"type": "string", "value": "hello"}),
795            json!({"type": "number", "value": 42}),
796        ];
797        tracker.add_console("log", "hello 42", raw_args);
798
799        let result = tracker.get_console_json();
800        let messages = result.get("messages").unwrap().as_array().unwrap();
801        assert_eq!(messages.len(), 1);
802        assert_eq!(messages[0].get("text").unwrap(), "hello 42");
803        let args = messages[0].get("args").unwrap().as_array().unwrap();
804        assert_eq!(args.len(), 2);
805        assert_eq!(args[0], json!({"type": "string", "value": "hello"}));
806        assert_eq!(args[1], json!({"type": "number", "value": 42}));
807    }
808
809    #[test]
810    fn test_console_json_empty_args_omits_field() {
811        let mut tracker = EventTracker::new();
812        tracker.add_console("log", "text only", vec![]);
813
814        let result = tracker.get_console_json();
815        let messages = result.get("messages").unwrap().as_array().unwrap();
816        assert!(messages[0].get("args").is_none());
817    }
818
819    // -- format_console_arg: primitives --
820
821    #[test]
822    fn test_format_arg_string() {
823        let arg = json!({"type": "string", "value": "hello"});
824        assert_eq!(format_console_arg(&arg), Some("hello".to_string()));
825    }
826
827    #[test]
828    fn test_format_arg_number() {
829        let arg = json!({"type": "number", "value": 42});
830        assert_eq!(format_console_arg(&arg), Some("42".to_string()));
831    }
832
833    #[test]
834    fn test_format_arg_null() {
835        let arg = json!({"type": "object", "subtype": "null", "value": null});
836        assert_eq!(format_console_arg(&arg), Some("null".to_string()));
837    }
838
839    #[test]
840    fn test_format_arg_undefined() {
841        let arg = json!({"type": "undefined"});
842        assert_eq!(format_console_arg(&arg), Some("undefined".to_string()));
843    }
844
845    // -- format_console_arg: objects with preview --
846
847    #[test]
848    fn test_format_arg_object_preview() {
849        let arg = json!({
850            "type": "object",
851            "preview": {
852                "properties": [
853                    {"name": "userId", "type": "string", "value": "abc123"},
854                    {"name": "count", "type": "number", "value": "42"}
855                ],
856                "overflow": false
857            }
858        });
859        assert_eq!(
860            format_console_arg(&arg),
861            Some("{userId: \"abc123\", count: 42}".to_string())
862        );
863    }
864
865    #[test]
866    fn test_format_arg_object_preview_overflow() {
867        let arg = json!({
868            "type": "object",
869            "preview": {
870                "properties": [
871                    {"name": "a", "type": "number", "value": "1"}
872                ],
873                "overflow": true
874            }
875        });
876        assert_eq!(format_console_arg(&arg), Some("{a: 1, ...}".to_string()));
877    }
878
879    // -- format_console_arg: arrays with preview --
880
881    #[test]
882    fn test_format_arg_array_preview() {
883        let arg = json!({
884            "type": "object",
885            "subtype": "array",
886            "preview": {
887                "subtype": "array",
888                "properties": [
889                    {"name": "0", "type": "number", "value": "1"},
890                    {"name": "1", "type": "number", "value": "2"},
891                    {"name": "2", "type": "number", "value": "3"}
892                ],
893                "overflow": false
894            }
895        });
896        assert_eq!(format_console_arg(&arg), Some("[1, 2, 3]".to_string()));
897    }
898
899    // -- format_console_arg: map/set use description --
900
901    #[test]
902    fn test_format_arg_map_uses_description() {
903        let arg = json!({
904            "type": "object",
905            "subtype": "map",
906            "description": "Map(1)",
907            "preview": {
908                "subtype": "map",
909                "properties": [{"name": "size", "type": "number", "value": "1"}]
910            }
911        });
912        assert_eq!(format_console_arg(&arg), Some("Map(1)".to_string()));
913    }
914
915    // -- format_console_arg: fallback --
916
917    #[test]
918    fn test_format_arg_description_fallback() {
919        let arg = json!({"type": "object", "description": "RegExp"});
920        assert_eq!(format_console_arg(&arg), Some("RegExp".to_string()));
921    }
922
923    #[test]
924    fn test_format_arg_no_value_no_preview_no_description() {
925        let arg = json!({"type": "object"});
926        assert_eq!(format_console_arg(&arg), None);
927    }
928
929    // -- format_console_args --
930
931    #[test]
932    fn test_format_console_args_join() {
933        let args = vec![
934            json!({"type": "string", "value": "user"}),
935            json!({
936                "type": "object",
937                "preview": {
938                    "properties": [{"name": "id", "type": "number", "value": "1"}],
939                    "overflow": false
940                }
941            }),
942        ];
943        assert_eq!(format_console_args(&args), "user {id: 1}");
944    }
945
946    #[test]
947    fn test_format_console_args_filters_none() {
948        // An arg that returns None should be skipped, not produce empty string
949        let args = vec![
950            json!({"type": "string", "value": "before"}),
951            json!({"type": "object"}), // no value, preview, or description → None
952            json!({"type": "string", "value": "after"}),
953        ];
954        assert_eq!(format_console_args(&args), "before after");
955    }
956}