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
37pub 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 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#[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
463pub 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
484pub 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
499pub 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 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 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 arg.get("description")
577 .and_then(|v| v.as_str())
578 .map(|s| s.to_string())
579}
580
581pub fn format_console_args(args: &[Value]) -> String {
583 args.iter()
584 .filter_map(format_console_arg)
585 .collect::<Vec<_>>()
586 .join(" ")
587}
588
589#[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 #[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 #[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 #[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 #[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 #[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 #[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 let args = vec![
927 json!({"type": "string", "value": "before"}),
928 json!({"type": "object"}), json!({"type": "string", "value": "after"}),
930 ];
931 assert_eq!(format_console_args(&args), "before after");
932 }
933}