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::client::CdpClient;
use super::cdp::types::{
AttachToTargetParams, AttachToTargetResult, CdpEvent, ConsoleApiCalledEvent,
CreateTargetResult, DispatchMouseEventParams, ExceptionThrownEvent,
JavascriptDialogOpeningEvent, TargetCreatedEvent, TargetDestroyedEvent, TargetInfoChangedEvent,
AttachToTargetParams, AttachToTargetResult, CdpEvent, CreateTargetResult,
DispatchMouseEventParams, ExceptionThrownEvent, JavascriptDialogOpeningEvent,
TargetCreatedEvent, TargetDestroyedEvent, TargetInfoChangedEvent,
};
use super::cookies;
use super::diff;
@@ -733,29 +733,22 @@ impl DaemonState {
match event.method.as_str() {
"Runtime.consoleAPICalled" => {
if let Ok(console_event) = serde_json::from_value::<ConsoleApiCalledEvent>(
event.params.clone(),
) {
let text: String = console_event
.args
.iter()
.filter_map(|arg| {
arg.value
.as_ref()
.map(|v| match v {
Value::String(s) => s.clone(),
other => other.to_string(),
})
.or_else(|| arg.description.clone())
})
.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);
}
let level = event
.params
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("log");
let raw_args: Vec<Value> = event
.params
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let text = network::format_console_args(&raw_args);
if let Some(ref server) = self.stream_server {
server.broadcast_console(level, &text, &raw_args);
}
self.event_tracker.add_console(level, &text, raw_args);
}
"Runtime.exceptionThrown" => {
if let Ok(ex_event) =
+1
View File
@@ -260,6 +260,7 @@ pub struct RemoteObject {
pub object_id: Option<String>,
pub class_name: Option<String>,
pub unserializable_value: Option<String>,
pub preview: Option<Value>,
}
#[derive(Debug, Clone, Deserialize)]
+270 -3
View File
@@ -261,6 +261,96 @@ pub async fn install_domain_filter(
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
// ---------------------------------------------------------------------------
@@ -269,6 +359,7 @@ pub async fn install_domain_filter(
pub struct ConsoleEntry {
pub level: String,
pub text: String,
pub args: Vec<Value>,
}
#[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 {
self.console_entries.remove(0);
}
self.console_entries.push(ConsoleEntry {
level: level.to_string(),
text: text.to_string(),
args,
});
}
@@ -330,7 +422,15 @@ impl EventTracker {
let messages: Vec<Value> = self
.console_entries
.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();
json!({ "messages": messages })
}
@@ -396,10 +496,177 @@ mod tests {
#[test]
fn test_event_tracker() {
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));
assert_eq!(tracker.console_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 super::cdp::client::CdpClient;
use super::network;
#[cfg(windows)]
use crate::connection::get_port_for_session;
use crate::connection::get_socket_dir;
#[cfg(windows)]
use crate::connection::resolve_port;
@@ -393,13 +396,18 @@ impl StreamServer {
}
/// Broadcast a console event from the browser.
pub fn broadcast_console(&self, level: &str, text: &str) {
let msg = json!({
pub fn broadcast_console(&self, level: &str, text: &str, args: &[Value]) {
let mut msg = json!({
"type": "console",
"level": level,
"text": text,
"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());
}
@@ -893,29 +901,24 @@ async fn cdp_event_loop(
let level = evt.params.get("type")
.and_then(|v| v.as_str())
.unwrap_or("log");
let text = evt.params.get("args")
let raw_args = evt.params.get("args")
.and_then(|v| v.as_array())
.map(|args| {
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(" ")
})
.cloned()
.unwrap_or_default();
let text = network::format_console_args(&raw_args);
if !text.is_empty() {
let msg = json!({
let mut msg = json!({
"type": "console",
"level": level,
"text": text,
"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());
}
} else if evt.method == "Runtime.exceptionThrown" {