fix: expose raw CDP args in console output and use preview for formatting (#1040)

Closes #1039

- Add `preview` field to `RemoteObject` to capture CDP object previews
- Implement `format_console_arg` using preview data (value → preview → description)
- Store raw CDP args in `ConsoleEntry` and include in JSON output
- Skip typed `ConsoleApiCalledEvent` deserialization in favor of direct param extraction
- Unify console arg formatting between daemon (actions.rs) and stream (stream.rs)

Before: `console.log({userId: "abc", count: 42})` → `"Object"`
After:  `console.log({userId: "abc", count: 42})` → `{userId: "abc", count: 42}`

JSON output now includes raw `args` array for programmatic access by AI agents.

Co-authored-by: hyunjinee <leehj0110@kakao.com>
This commit is contained in:
jin.2
2026-03-29 12:24:27 -06:00
committed by GitHub
co-authored by hyunjinee
parent 6dd53449e8
commit 369f48752a
4 changed files with 309 additions and 45 deletions
+18 -25
View File
@@ -16,9 +16,9 @@ use super::browser::{should_track_target, BrowserManager, WaitUntil};
use super::cdp::chrome::LaunchOptions; use super::cdp::chrome::LaunchOptions;
use super::cdp::client::CdpClient; use super::cdp::client::CdpClient;
use super::cdp::types::{ use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent, AttachToTargetParams, AttachToTargetResult, CdpEvent, CreateTargetResult,
CreateTargetResult, DispatchMouseEventParams, ExceptionThrownEvent, DispatchMouseEventParams, ExceptionThrownEvent, JavascriptDialogOpeningEvent,
JavascriptDialogOpeningEvent, TargetCreatedEvent, TargetDestroyedEvent, TargetInfoChangedEvent, TargetCreatedEvent, TargetDestroyedEvent, TargetInfoChangedEvent,
}; };
use super::cookies; use super::cookies;
use super::diff; use super::diff;
@@ -733,29 +733,22 @@ impl DaemonState {
match event.method.as_str() { match event.method.as_str() {
"Runtime.consoleAPICalled" => { "Runtime.consoleAPICalled" => {
if let Ok(console_event) = serde_json::from_value::<ConsoleApiCalledEvent>( let level = event
event.params.clone(), .params
) { .get("type")
let text: String = console_event .and_then(|v| v.as_str())
.args .unwrap_or("log");
.iter() let raw_args: Vec<Value> = event
.filter_map(|arg| { .params
arg.value .get("args")
.as_ref() .and_then(|v| v.as_array())
.map(|v| match v { .cloned()
Value::String(s) => s.clone(), .unwrap_or_default();
other => other.to_string(), let text = network::format_console_args(&raw_args);
}) if let Some(ref server) = self.stream_server {
.or_else(|| arg.description.clone()) server.broadcast_console(level, &text, &raw_args);
})
.collect::<Vec<_>>()
.join(" ");
self.event_tracker
.add_console(&console_event.call_type, &text);
if let Some(ref server) = self.stream_server {
server.broadcast_console(&console_event.call_type, &text);
}
} }
self.event_tracker.add_console(level, &text, raw_args);
} }
"Runtime.exceptionThrown" => { "Runtime.exceptionThrown" => {
if let Ok(ex_event) = if let Ok(ex_event) =
+1
View File
@@ -260,6 +260,7 @@ pub struct RemoteObject {
pub object_id: Option<String>, pub object_id: Option<String>,
pub class_name: Option<String>, pub class_name: Option<String>,
pub unserializable_value: Option<String>, pub unserializable_value: Option<String>,
pub preview: Option<Value>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
+270 -3
View File
@@ -261,6 +261,96 @@ pub async fn install_domain_filter(
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// Console arg formatting (CDP RemoteObject → human-readable string)
// ---------------------------------------------------------------------------
/// Format a single CDP RemoteObject arg into a human-readable string.
/// Priority: value → preview → description.
pub fn format_console_arg(arg: &Value) -> Option<String> {
let obj_type = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
let subtype = arg.get("subtype").and_then(|v| v.as_str());
if obj_type == "undefined" {
return Some("undefined".to_string());
}
if subtype == Some("null") {
return Some("null".to_string());
}
// Primitive value
if let Some(v) = arg.get("value") {
return Some(match v {
Value::String(s) => s.clone(),
Value::Null => "null".to_string(),
other => other.to_string(),
});
}
// Skip preview for Map/Set — their description ("Map(1)", "Set(3)") is more useful
// than their preview properties (which only show "size")
if let Some(preview) = arg.get("preview") {
let preview_subtype = preview.get("subtype").and_then(|v| v.as_str());
if matches!(preview_subtype, Some("map" | "set" | "weakmap" | "weakset")) {
return arg
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
let is_array = subtype == Some("array") || preview_subtype == Some("array");
if let Some(props) = preview.get("properties").and_then(|v| v.as_array()) {
let overflow = preview
.get("overflow")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let formatted_props: Vec<String> = props
.iter()
.filter_map(|p| {
let value_str = p.get("value").and_then(|v| v.as_str())?;
let prop_type = p.get("type").and_then(|v| v.as_str()).unwrap_or("");
let formatted_value = if prop_type == "string" {
format!("\"{}\"", value_str)
} else {
value_str.to_string()
};
if is_array {
Some(formatted_value)
} else {
let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?");
Some(format!("{}: {}", name, formatted_value))
}
})
.collect();
let inner = if overflow {
format!("{}, ...", formatted_props.join(", "))
} else {
formatted_props.join(", ")
};
return if is_array {
Some(format!("[{}]", inner))
} else {
Some(format!("{{{}}}", inner))
};
}
}
// Fallback to description
arg.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// Format an array of CDP RemoteObject args into a single space-separated string.
pub fn format_console_args(args: &[Value]) -> String {
args.iter()
.filter_map(format_console_arg)
.collect::<Vec<_>>()
.join(" ")
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Console and error tracking // Console and error tracking
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -269,6 +359,7 @@ pub async fn install_domain_filter(
pub struct ConsoleEntry { pub struct ConsoleEntry {
pub level: String, pub level: String,
pub text: String, pub text: String,
pub args: Vec<Value>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -294,13 +385,14 @@ impl EventTracker {
} }
} }
pub fn add_console(&mut self, level: &str, text: &str) { pub fn add_console(&mut self, level: &str, text: &str, args: Vec<Value>) {
if self.console_entries.len() >= self.max_entries { if self.console_entries.len() >= self.max_entries {
self.console_entries.remove(0); self.console_entries.remove(0);
} }
self.console_entries.push(ConsoleEntry { self.console_entries.push(ConsoleEntry {
level: level.to_string(), level: level.to_string(),
text: text.to_string(), text: text.to_string(),
args,
}); });
} }
@@ -330,7 +422,15 @@ impl EventTracker {
let messages: Vec<Value> = self let messages: Vec<Value> = self
.console_entries .console_entries
.iter() .iter()
.map(|e| json!({ "type": e.level, "text": e.text })) .map(|e| {
let mut msg = json!({ "type": e.level, "text": e.text });
if !e.args.is_empty() {
msg.as_object_mut()
.unwrap()
.insert("args".to_string(), Value::Array(e.args.clone()));
}
msg
})
.collect(); .collect();
json!({ "messages": messages }) json!({ "messages": messages })
} }
@@ -396,10 +496,177 @@ mod tests {
#[test] #[test]
fn test_event_tracker() { fn test_event_tracker() {
let mut tracker = EventTracker::new(); let mut tracker = EventTracker::new();
tracker.add_console("log", "hello"); tracker.add_console("log", "hello", vec![]);
tracker.add_error("oops", Some("test.js"), Some(1), Some(5)); tracker.add_error("oops", Some("test.js"), Some(1), Some(5));
assert_eq!(tracker.console_entries.len(), 1); assert_eq!(tracker.console_entries.len(), 1);
assert_eq!(tracker.error_entries.len(), 1); assert_eq!(tracker.error_entries.len(), 1);
} }
#[test]
fn test_console_json_includes_args() {
let mut tracker = EventTracker::new();
let raw_args = vec![
json!({"type": "string", "value": "hello"}),
json!({"type": "number", "value": 42}),
];
tracker.add_console("log", "hello 42", raw_args);
let result = tracker.get_console_json();
let messages = result.get("messages").unwrap().as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].get("text").unwrap(), "hello 42");
let args = messages[0].get("args").unwrap().as_array().unwrap();
assert_eq!(args.len(), 2);
assert_eq!(args[0], json!({"type": "string", "value": "hello"}));
assert_eq!(args[1], json!({"type": "number", "value": 42}));
}
#[test]
fn test_console_json_empty_args_omits_field() {
let mut tracker = EventTracker::new();
tracker.add_console("log", "text only", vec![]);
let result = tracker.get_console_json();
let messages = result.get("messages").unwrap().as_array().unwrap();
assert!(messages[0].get("args").is_none());
}
// -- format_console_arg: primitives --
#[test]
fn test_format_arg_string() {
let arg = json!({"type": "string", "value": "hello"});
assert_eq!(format_console_arg(&arg), Some("hello".to_string()));
}
#[test]
fn test_format_arg_number() {
let arg = json!({"type": "number", "value": 42});
assert_eq!(format_console_arg(&arg), Some("42".to_string()));
}
#[test]
fn test_format_arg_null() {
let arg = json!({"type": "object", "subtype": "null", "value": null});
assert_eq!(format_console_arg(&arg), Some("null".to_string()));
}
#[test]
fn test_format_arg_undefined() {
let arg = json!({"type": "undefined"});
assert_eq!(format_console_arg(&arg), Some("undefined".to_string()));
}
// -- format_console_arg: objects with preview --
#[test]
fn test_format_arg_object_preview() {
let arg = json!({
"type": "object",
"preview": {
"properties": [
{"name": "userId", "type": "string", "value": "abc123"},
{"name": "count", "type": "number", "value": "42"}
],
"overflow": false
}
});
assert_eq!(
format_console_arg(&arg),
Some("{userId: \"abc123\", count: 42}".to_string())
);
}
#[test]
fn test_format_arg_object_preview_overflow() {
let arg = json!({
"type": "object",
"preview": {
"properties": [
{"name": "a", "type": "number", "value": "1"}
],
"overflow": true
}
});
assert_eq!(format_console_arg(&arg), Some("{a: 1, ...}".to_string()));
}
// -- format_console_arg: arrays with preview --
#[test]
fn test_format_arg_array_preview() {
let arg = json!({
"type": "object",
"subtype": "array",
"preview": {
"subtype": "array",
"properties": [
{"name": "0", "type": "number", "value": "1"},
{"name": "1", "type": "number", "value": "2"},
{"name": "2", "type": "number", "value": "3"}
],
"overflow": false
}
});
assert_eq!(format_console_arg(&arg), Some("[1, 2, 3]".to_string()));
}
// -- format_console_arg: map/set use description --
#[test]
fn test_format_arg_map_uses_description() {
let arg = json!({
"type": "object",
"subtype": "map",
"description": "Map(1)",
"preview": {
"subtype": "map",
"properties": [{"name": "size", "type": "number", "value": "1"}]
}
});
assert_eq!(format_console_arg(&arg), Some("Map(1)".to_string()));
}
// -- format_console_arg: fallback --
#[test]
fn test_format_arg_description_fallback() {
let arg = json!({"type": "object", "description": "RegExp"});
assert_eq!(format_console_arg(&arg), Some("RegExp".to_string()));
}
#[test]
fn test_format_arg_no_value_no_preview_no_description() {
let arg = json!({"type": "object"});
assert_eq!(format_console_arg(&arg), None);
}
// -- format_console_args --
#[test]
fn test_format_console_args_join() {
let args = vec![
json!({"type": "string", "value": "user"}),
json!({
"type": "object",
"preview": {
"properties": [{"name": "id", "type": "number", "value": "1"}],
"overflow": false
}
}),
];
assert_eq!(format_console_args(&args), "user {id: 1}");
}
#[test]
fn test_format_console_args_filters_none() {
// An arg that returns None should be skipped, not produce empty string
let args = vec![
json!({"type": "string", "value": "before"}),
json!({"type": "object"}), // no value, preview, or description → None
json!({"type": "string", "value": "after"}),
];
assert_eq!(format_console_args(&args), "before after");
}
} }
+20 -17
View File
@@ -10,6 +10,9 @@ use tokio::sync::{broadcast, watch, Mutex, Notify, RwLock};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use super::cdp::client::CdpClient; use super::cdp::client::CdpClient;
use super::network;
#[cfg(windows)]
use crate::connection::get_port_for_session;
use crate::connection::get_socket_dir; use crate::connection::get_socket_dir;
#[cfg(windows)] #[cfg(windows)]
use crate::connection::resolve_port; use crate::connection::resolve_port;
@@ -393,13 +396,18 @@ impl StreamServer {
} }
/// Broadcast a console event from the browser. /// Broadcast a console event from the browser.
pub fn broadcast_console(&self, level: &str, text: &str) { pub fn broadcast_console(&self, level: &str, text: &str, args: &[Value]) {
let msg = json!({ let mut msg = json!({
"type": "console", "type": "console",
"level": level, "level": level,
"text": text, "text": text,
"timestamp": timestamp_ms(), "timestamp": timestamp_ms(),
}); });
if !args.is_empty() {
msg.as_object_mut()
.unwrap()
.insert("args".to_string(), Value::Array(args.to_vec()));
}
let _ = self.frame_tx.send(msg.to_string()); let _ = self.frame_tx.send(msg.to_string());
} }
@@ -893,29 +901,24 @@ async fn cdp_event_loop(
let level = evt.params.get("type") let level = evt.params.get("type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("log"); .unwrap_or("log");
let text = evt.params.get("args") let raw_args = evt.params.get("args")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
.map(|args| { .cloned()
args.iter()
.filter_map(|arg| {
arg.get("value")
.map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.or_else(|| arg.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()))
})
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default(); .unwrap_or_default();
let text = network::format_console_args(&raw_args);
if !text.is_empty() { if !text.is_empty() {
let msg = json!({ let mut msg = json!({
"type": "console", "type": "console",
"level": level, "level": level,
"text": text, "text": text,
"timestamp": timestamp_ms(), "timestamp": timestamp_ms(),
}); });
if !raw_args.is_empty() {
msg.as_object_mut().unwrap().insert(
"args".to_string(),
Value::Array(raw_args),
);
}
let _ = frame_tx.send(msg.to_string()); let _ = frame_tx.send(msg.to_string());
} }
} else if evt.method == "Runtime.exceptionThrown" { } else if evt.method == "Runtime.exceptionThrown" {