Skip to main content

browser_automation_cli/native/
network.rs

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