1#![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
45pub 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 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#[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 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
486pub 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
507pub 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
522pub 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 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 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 arg.get("description")
600 .and_then(|v| v.as_str())
601 .map(|s| s.to_string())
602}
603
604pub fn format_console_args(args: &[Value]) -> String {
606 args.iter()
607 .filter_map(format_console_arg)
608 .collect::<Vec<_>>()
609 .join(" ")
610}
611
612#[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 #[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 #[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 #[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 #[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 #[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 #[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 let args = vec![
950 json!({"type": "string", "value": "before"}),
951 json!({"type": "object"}), json!({"type": "string", "value": "after"}),
953 ];
954 assert_eq!(format_console_args(&args), "before after");
955 }
956}