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:
+18
-25
@@ -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) =
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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" {
|
||||||
|
|||||||
Reference in New Issue
Block a user