use std::collections::HashMap;
use std::fmt::Write;
use serde::Serialize;
use agentchrome::connection::ManagedSession;
use agentchrome::error::{AppError, ExitCode};
use crate::cli::{
DomArgs, DomCommand, DomGetAttributeArgs, DomGetStyleArgs, DomNodeIdArgs, DomSelectArgs,
DomSetAttributeArgs, DomSetStyleArgs, DomSetTextArgs, DomTreeArgs, GlobalOpts,
};
use crate::output::{self, print_output, setup_session_with_interceptors as setup_session};
use crate::snapshot;
#[derive(Serialize)]
struct DomElement {
#[serde(rename = "nodeId")]
node_id: i64,
tag: String,
attributes: HashMap<String, String>,
#[serde(rename = "textContent")]
text_content: String,
}
#[derive(Serialize)]
struct AttributeResult {
attribute: String,
value: String,
}
#[derive(Serialize)]
struct TextResult {
#[serde(rename = "textContent")]
text_content: String,
}
#[derive(Serialize)]
struct HtmlResult {
#[serde(rename = "outerHTML")]
outer_html: String,
}
#[derive(Serialize)]
struct MutationResult {
success: bool,
#[serde(rename = "nodeId")]
node_id: i64,
#[serde(skip_serializing_if = "Option::is_none")]
attribute: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<String>,
}
#[derive(Serialize)]
struct SetTextResult {
success: bool,
#[serde(rename = "nodeId")]
node_id: i64,
#[serde(rename = "textContent")]
text_content: String,
}
#[derive(Serialize)]
struct RemoveResult {
success: bool,
#[serde(rename = "nodeId")]
node_id: i64,
removed: bool,
}
#[derive(Serialize)]
struct SetStyleResult {
success: bool,
#[serde(rename = "nodeId")]
node_id: i64,
style: String,
}
#[derive(Serialize)]
struct StyleResult {
styles: HashMap<String, String>,
}
#[derive(Serialize)]
struct StylePropertyResult {
property: String,
value: String,
}
#[derive(Serialize)]
struct TreeOutput {
tree: String,
}
#[derive(Serialize)]
struct EventHandler {
description: String,
#[serde(rename = "scriptId")]
script_id: Option<String>,
#[serde(rename = "lineNumber")]
line_number: Option<i64>,
#[serde(rename = "columnNumber")]
column_number: Option<i64>,
}
#[derive(Serialize)]
struct EventListenerInfo {
#[serde(rename = "type")]
event_type: String,
#[serde(rename = "useCapture")]
use_capture: bool,
once: bool,
passive: bool,
handler: EventHandler,
}
#[derive(Serialize)]
struct EventListenersResult {
listeners: Vec<EventListenerInfo>,
}
async fn get_document_root(
session: &ManagedSession,
_frame_ctx: Option<&agentchrome::frame::FrameContext>,
) -> Result<i64, AppError> {
let doc = session
.send_command("DOM.getDocument", None)
.await
.map_err(|e| AppError {
message: format!("DOM.getDocument failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
doc["root"]["nodeId"]
.as_i64()
.ok_or_else(|| AppError::node_not_found("root"))
}
async fn query_selector_in_context(
session: &ManagedSession,
selector: &str,
context_id: i64,
) -> Result<ResolvedNode, AppError> {
let escaped = selector.replace('\\', "\\\\").replace('"', "\\\"");
let js = format!(r#"document.querySelector("{escaped}")"#);
let eval = session
.send_command(
"Runtime.evaluate",
Some(serde_json::json!({
"expression": js,
"contextId": context_id,
})),
)
.await
.map_err(|e| AppError {
message: format!("CSS selector query failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
if let Some(exc) = eval.get("exceptionDetails") {
let desc = exc["exception"]["description"]
.as_str()
.unwrap_or("unknown error");
return Err(AppError {
message: format!("CSS selector query failed: {desc}"),
code: ExitCode::ProtocolError,
custom_json: None,
});
}
let result_type = eval["result"]["type"].as_str().unwrap_or("undefined");
if result_type == "undefined"
|| (result_type == "object" && eval["result"]["subtype"].as_str() == Some("null"))
{
return Err(AppError::css_selector_not_found(selector));
}
let object_id = eval["result"]["objectId"]
.as_str()
.ok_or_else(|| AppError::css_selector_not_found(selector))?;
let _ = session.send_command("DOM.getDocument", None).await;
let node_result = session
.send_command(
"DOM.requestNode",
Some(serde_json::json!({ "objectId": object_id })),
)
.await
.map_err(|e| AppError {
message: format!("DOM.requestNode failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let node_id = node_result["nodeId"]
.as_i64()
.filter(|&id| id > 0)
.ok_or_else(|| AppError::css_selector_not_found(selector))?;
let backend_node_id = get_backend_node_id(session, node_id)
.await
.unwrap_or(node_id);
Ok(ResolvedNode {
node_id,
backend_node_id,
})
}
async fn query_selector_all_in_context(
session: &ManagedSession,
selector: &str,
context_id: i64,
) -> Result<Vec<i64>, AppError> {
let escaped = selector.replace('\\', "\\\\").replace('"', "\\\"");
let js = format!(r#"Array.from(document.querySelectorAll("{escaped}"))"#);
let eval = session
.send_command(
"Runtime.evaluate",
Some(serde_json::json!({
"expression": js,
"contextId": context_id,
})),
)
.await
.map_err(|e| AppError {
message: format!("CSS selector query failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let array_obj_id = match eval["result"]["objectId"].as_str() {
Some(id) => id.to_string(),
None => return Ok(vec![]),
};
let props = session
.send_command(
"Runtime.getProperties",
Some(serde_json::json!({
"objectId": array_obj_id,
"ownProperties": true,
})),
)
.await
.map_err(|e| AppError {
message: format!("Failed to get query results: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let mut node_ids = Vec::new();
if let Some(results) = props["result"].as_array() {
for prop in results {
if prop["name"]
.as_str()
.and_then(|n| n.parse::<u32>().ok())
.is_none()
{
continue;
}
let Some(obj_id) = prop["value"]["objectId"].as_str() else {
continue;
};
let node_result = session
.send_command(
"DOM.requestNode",
Some(serde_json::json!({ "objectId": obj_id })),
)
.await;
if let Ok(nr) = node_result {
if let Some(nid) = nr["nodeId"].as_i64().filter(|&id| id > 0) {
node_ids.push(nid);
}
}
}
}
Ok(node_ids)
}
struct ResolvedNode {
node_id: i64,
backend_node_id: i64,
}
async fn resolve_node(
session: &ManagedSession,
target: &str,
frame_ctx: Option<&agentchrome::frame::FrameContext>,
) -> Result<ResolvedNode, AppError> {
if let Ok(backend_node_id) = target.parse::<i64>() {
let node_id = push_backend_node_to_frontend(session, backend_node_id, target).await?;
return Ok(ResolvedNode {
node_id,
backend_node_id,
});
}
if snapshot::is_uid(target) {
let state = snapshot::read_snapshot_state()?.ok_or_else(AppError::no_snapshot_state)?;
let backend_node_id = state
.uid_map
.get(target)
.copied()
.ok_or_else(|| AppError::uid_not_found(target))?;
let node_id = push_backend_node_to_frontend(session, backend_node_id, target)
.await
.map_err(|_| AppError::stale_uid(target))?;
return Ok(ResolvedNode {
node_id,
backend_node_id,
});
}
if snapshot::is_css_selector(target) {
let selector = &target[4..];
if let Some(ctx_id) = frame_ctx.and_then(agentchrome::frame::execution_context_id) {
return query_selector_in_context(session, selector, ctx_id).await;
}
let root_id = get_document_root(session, frame_ctx).await?;
let query = session
.send_command(
"DOM.querySelector",
Some(serde_json::json!({
"nodeId": root_id,
"selector": selector,
})),
)
.await
.map_err(|e| AppError {
message: format!("CSS selector query failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let node_id = query["nodeId"]
.as_i64()
.filter(|&id| id > 0)
.ok_or_else(|| AppError::element_not_found(selector))?;
let backend_node_id = get_backend_node_id(session, node_id)
.await
.unwrap_or(node_id);
return Ok(ResolvedNode {
node_id,
backend_node_id,
});
}
Err(AppError::node_not_found(target))
}
async fn push_backend_node_to_frontend(
session: &ManagedSession,
backend_node_id: i64,
label: &str,
) -> Result<i64, AppError> {
let _ = get_document_root(session, None).await?;
let resolve = session
.send_command(
"DOM.resolveNode",
Some(serde_json::json!({ "backendNodeId": backend_node_id })),
)
.await
.map_err(|_| AppError::node_not_found(label))?;
let object_id = resolve["object"]["objectId"]
.as_str()
.ok_or_else(|| AppError::node_not_found(label))?;
let request = session
.send_command(
"DOM.requestNode",
Some(serde_json::json!({ "objectId": object_id })),
)
.await
.map_err(|_| AppError::node_not_found(label))?;
request["nodeId"]
.as_i64()
.filter(|&id| id > 0)
.ok_or_else(|| AppError::node_not_found(label))
}
async fn get_backend_node_id(session: &ManagedSession, node_id: i64) -> Result<i64, AppError> {
let describe = session
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "nodeId": node_id })),
)
.await
.map_err(|_| AppError::node_not_found(&node_id.to_string()))?;
describe["node"]["backendNodeId"]
.as_i64()
.ok_or_else(|| AppError::node_not_found(&node_id.to_string()))
}
async fn describe_element(session: &ManagedSession, node_id: i64) -> Result<DomElement, AppError> {
let describe = session
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "nodeId": node_id })),
)
.await
.map_err(|_| AppError::node_not_found(&node_id.to_string()))?;
let node = &describe["node"];
let tag = node["nodeName"].as_str().unwrap_or("").to_lowercase();
let backend_node_id = node["backendNodeId"].as_i64().unwrap_or(node_id);
let mut attributes = HashMap::new();
if let Some(attrs) = node["attributes"].as_array() {
let mut i = 0;
while i + 1 < attrs.len() {
let name = attrs[i].as_str().unwrap_or("").to_string();
let value = attrs[i + 1].as_str().unwrap_or("").to_string();
attributes.insert(name, value);
i += 2;
}
}
let text_content = get_text_content(session, node_id).await.unwrap_or_default();
Ok(DomElement {
node_id: backend_node_id,
tag,
attributes,
text_content,
})
}
async fn get_text_content(session: &ManagedSession, node_id: i64) -> Result<String, AppError> {
let resolve = session
.send_command(
"DOM.resolveNode",
Some(serde_json::json!({ "nodeId": node_id })),
)
.await?;
let object_id = resolve["object"]["objectId"]
.as_str()
.ok_or_else(|| AppError::node_not_found(&node_id.to_string()))?;
let call = session
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": "function() { return this.textContent || ''; }",
"returnByValue": true,
})),
)
.await?;
Ok(call["result"]["value"].as_str().unwrap_or("").to_string())
}
async fn find_in_shadow_dom(
session: &ManagedSession,
selector: &str,
) -> Result<Vec<i64>, AppError> {
let escaped = serde_json::to_string(selector).unwrap_or_default();
let expression = format!(
r"(function() {{
function findInShadow(root, sel) {{
var results = [];
var els = root.querySelectorAll(sel);
for (var i = 0; i < els.length; i++) results.push(els[i]);
var all = root.querySelectorAll('*');
for (var i = 0; i < all.length; i++) {{
if (all[i].shadowRoot) {{
var inner = findInShadow(all[i].shadowRoot, sel);
for (var j = 0; j < inner.length; j++) results.push(inner[j]);
}}
}}
return results;
}}
return findInShadow(document, {escaped});
}})()"
);
let result = session
.send_command(
"Runtime.evaluate",
Some(serde_json::json!({
"expression": expression,
"returnByValue": false,
})),
)
.await
.map_err(|e| AppError {
message: format!("Shadow DOM search failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let object_id = result["result"]["objectId"].as_str().unwrap_or_default();
if object_id.is_empty() {
return Ok(vec![]);
}
let props = session
.send_command(
"Runtime.getProperties",
Some(serde_json::json!({
"objectId": object_id,
"ownProperties": true,
})),
)
.await
.map_err(|e| AppError {
message: format!("Failed to get shadow DOM results: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let mut node_ids = Vec::new();
if let Some(props_arr) = props["result"].as_array() {
for prop in props_arr {
if prop["name"]
.as_str()
.and_then(|n| n.parse::<u32>().ok())
.is_none()
{
continue;
}
let elem_obj_id = prop["value"]["objectId"].as_str().unwrap_or_default();
if elem_obj_id.is_empty() {
continue;
}
if let Ok(req) = session
.send_command(
"DOM.requestNode",
Some(serde_json::json!({ "objectId": elem_obj_id })),
)
.await
{
if let Some(nid) = req["nodeId"].as_i64() {
if nid > 0 {
node_ids.push(nid);
}
}
}
}
}
Ok(node_ids)
}
fn summary_of_select(value: &serde_json::Value) -> serde_json::Value {
let Some(items) = value.as_array() else {
return serde_json::json!({
"match_count": serde_json::Value::Null,
"first_match": serde_json::Value::Null,
});
};
let match_count = items.len();
let first_match = items.first().map(|el| {
serde_json::json!({
"tag": el["tag"],
"role": serde_json::Value::Null,
"uid": el["nodeId"],
})
});
serde_json::json!({
"match_count": match_count,
"first_match": first_match.unwrap_or(serde_json::Value::Null),
})
}
fn summary_of_attributes(value: &serde_json::Value) -> serde_json::Value {
let styles_obj = value.get("styles").and_then(|s| s.as_object());
match styles_obj {
Some(styles) => {
let keys: Vec<serde_json::Value> = styles
.keys()
.map(|k| serde_json::Value::String(k.clone()))
.collect();
serde_json::json!({
"attribute_count": styles.len(),
"keys_seen": keys,
})
}
None => serde_json::json!({
"attribute_count": serde_json::Value::Null,
"keys_seen": serde_json::Value::Null,
}),
}
}
fn summary_of_events(value: &serde_json::Value) -> serde_json::Value {
let Some(listeners) = value.get("listeners").and_then(|l| l.as_array()) else {
return serde_json::json!({
"listener_count": serde_json::Value::Null,
"event_types": serde_json::Value::Null,
});
};
let listener_count = listeners.len();
let mut seen = std::collections::HashSet::new();
for listener in listeners {
if let Some(t) = listener["type"].as_str() {
seen.insert(t.to_string());
}
}
let mut event_types: Vec<serde_json::Value> =
seen.into_iter().map(serde_json::Value::String).collect();
event_types.sort_by(|a, b| a.as_str().unwrap_or("").cmp(b.as_str().unwrap_or("")));
serde_json::json!({
"listener_count": listener_count,
"event_types": event_types,
})
}
pub async fn execute_dom(global: &GlobalOpts, args: &DomArgs) -> Result<(), AppError> {
let frame = args.frame.as_deref();
let pierce_shadow = args.pierce_shadow;
match &args.command {
DomCommand::Select(select_args) => {
execute_select(global, select_args, frame, pierce_shadow).await
}
DomCommand::GetAttribute(attr_args) => {
execute_get_attribute(global, attr_args, frame).await
}
DomCommand::GetText(node_args) => execute_get_text(global, node_args, frame).await,
DomCommand::GetHtml(node_args) => execute_get_html(global, node_args, frame).await,
DomCommand::SetAttribute(attr_args) => {
execute_set_attribute(global, attr_args, frame).await
}
DomCommand::SetText(text_args) => execute_set_text(global, text_args, frame).await,
DomCommand::Remove(node_args) => execute_remove(global, node_args, frame).await,
DomCommand::GetStyle(style_args) => execute_get_style(global, style_args, frame).await,
DomCommand::SetStyle(style_args) => execute_set_style(global, style_args, frame).await,
DomCommand::Parent(node_args) => execute_parent(global, node_args, frame).await,
DomCommand::Children(node_args) => execute_children(global, node_args, frame).await,
DomCommand::Siblings(node_args) => execute_siblings(global, node_args, frame).await,
DomCommand::Tree(tree_args) => execute_tree(global, tree_args).await,
DomCommand::Events(node_args) => execute_events(global, node_args, frame).await,
}
}
#[allow(clippy::too_many_lines)]
async fn execute_select(
global: &GlobalOpts,
args: &DomSelectArgs,
frame: Option<&str>,
pierce_shadow: bool,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("Runtime").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let same_origin_ctx_id = frame_ctx
.as_ref()
.and_then(agentchrome::frame::execution_context_id);
let root_id = get_document_root(effective, frame_ctx.as_ref()).await?;
let mut node_ids = if args.xpath {
let search = effective
.send_command(
"DOM.performSearch",
Some(serde_json::json!({ "query": args.selector })),
)
.await
.map_err(|e| AppError {
message: format!("XPath search failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let search_id = search["searchId"].as_str().unwrap_or("").to_string();
let count = search["resultCount"].as_i64().unwrap_or(0);
let ids = if count > 0 {
let results = effective
.send_command(
"DOM.getSearchResults",
Some(serde_json::json!({
"searchId": search_id,
"fromIndex": 0,
"toIndex": count,
})),
)
.await?;
results["nodeIds"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(serde_json::Value::as_i64)
.collect::<Vec<_>>()
})
.unwrap_or_default()
} else {
vec![]
};
let _ = effective
.send_command(
"DOM.discardSearchResults",
Some(serde_json::json!({ "searchId": search_id })),
)
.await;
ids
} else if let Some(ctx_id) = same_origin_ctx_id {
let css_selector = if snapshot::is_css_selector(&args.selector) {
&args.selector[4..]
} else {
&args.selector
};
query_selector_all_in_context(effective, css_selector, ctx_id).await?
} else {
let query = effective
.send_command(
"DOM.querySelectorAll",
Some(serde_json::json!({
"nodeId": root_id,
"selector": args.selector,
})),
)
.await
.map_err(|e| AppError {
message: format!("CSS selector query failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
query["nodeIds"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(serde_json::Value::as_i64)
.collect::<Vec<_>>()
})
.unwrap_or_default()
};
if pierce_shadow && node_ids.is_empty() && !args.xpath {
if let Ok(shadow_ids) = find_in_shadow_dom(effective, &args.selector).await {
node_ids = shadow_ids;
}
}
let mut elements = Vec::with_capacity(node_ids.len());
for nid in node_ids {
if let Ok(el) = describe_element(effective, nid).await {
elements.push(el);
}
}
if global.output.plain {
for el in &elements {
let attrs: Vec<String> = el
.attributes
.iter()
.map(|(k, v)| format!("{k}=\"{v}\""))
.collect();
let attr_str = if attrs.is_empty() {
String::new()
} else {
format!(" {}", attrs.join(" "))
};
let text = truncate_text(&el.text_content, 60);
println!("[{}] <{}{}> \"{}\"", el.node_id, el.tag, attr_str, text);
}
return Ok(());
}
output::emit(&elements, &global.output, "dom select", |v| {
summary_of_select(&serde_json::to_value(v).unwrap_or_default())
})
}
async fn execute_get_attribute(
global: &GlobalOpts,
args: &DomGetAttributeArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let resolved = resolve_node(effective, &args.node_id, frame_ctx.as_ref()).await?;
let attrs = effective
.send_command(
"DOM.getAttributes",
Some(serde_json::json!({ "nodeId": resolved.node_id })),
)
.await
.map_err(|e| AppError {
message: format!("Failed to get attributes: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let attr_array = attrs["attributes"]
.as_array()
.ok_or_else(|| AppError::node_not_found(&args.node_id))?;
let mut i = 0;
while i + 1 < attr_array.len() {
let name = attr_array[i].as_str().unwrap_or("");
if name == args.attribute {
let value = attr_array[i + 1].as_str().unwrap_or("").to_string();
let result = AttributeResult {
attribute: args.attribute.clone(),
value: value.clone(),
};
if global.output.plain {
println!("{value}");
return Ok(());
}
return print_output(&result, &global.output);
}
i += 2;
}
Err(AppError::attribute_not_found(
&args.attribute,
&args.node_id,
))
}
async fn execute_get_text(
global: &GlobalOpts,
args: &DomNodeIdArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("Runtime").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let node_id = resolve_node(effective, &args.node_id, frame_ctx.as_ref())
.await?
.node_id;
let text = get_text_content(effective, node_id).await?;
if global.output.plain {
print!("{text}");
return Ok(());
}
let result = TextResult { text_content: text };
print_output(&result, &global.output)
}
async fn execute_get_html(
global: &GlobalOpts,
args: &DomNodeIdArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let node_id = resolve_node(effective, &args.node_id, frame_ctx.as_ref())
.await?
.node_id;
let html = effective
.send_command(
"DOM.getOuterHTML",
Some(serde_json::json!({ "nodeId": node_id })),
)
.await
.map_err(|e| AppError {
message: format!("Failed to get outerHTML: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let outer_html = html["outerHTML"].as_str().unwrap_or("").to_string();
if global.output.plain {
print!("{outer_html}");
return Ok(());
}
let result = HtmlResult { outer_html };
print_output(&result, &global.output)
}
async fn execute_set_attribute(
global: &GlobalOpts,
args: &DomSetAttributeArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let resolved = resolve_node(effective, &args.node_id, frame_ctx.as_ref()).await?;
effective
.send_command(
"DOM.setAttributeValue",
Some(serde_json::json!({
"nodeId": resolved.node_id,
"name": args.attribute,
"value": args.value,
})),
)
.await
.map_err(|e| AppError {
message: format!("Failed to set attribute: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let result = MutationResult {
success: true,
node_id: resolved.backend_node_id,
attribute: Some(args.attribute.clone()),
value: Some(args.value.clone()),
};
if global.output.plain {
println!(
"Set {}=\"{}\" on node {}",
args.attribute, args.value, resolved.backend_node_id
);
return Ok(());
}
print_output(&result, &global.output)
}
async fn execute_set_text(
global: &GlobalOpts,
args: &DomSetTextArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("Runtime").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let resolved = resolve_node(effective, &args.node_id, frame_ctx.as_ref()).await?;
let resolve = effective
.send_command(
"DOM.resolveNode",
Some(serde_json::json!({ "nodeId": resolved.node_id })),
)
.await
.map_err(|_| AppError::node_not_found(&args.node_id))?;
let object_id = resolve["object"]["objectId"]
.as_str()
.ok_or_else(|| AppError::node_not_found(&args.node_id))?;
effective
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": "function(text) { this.textContent = text; }",
"arguments": [{ "value": args.text }],
})),
)
.await
.map_err(|e| AppError {
message: format!("Failed to set text: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let result = SetTextResult {
success: true,
node_id: resolved.backend_node_id,
text_content: args.text.clone(),
};
if global.output.plain {
println!("Set text on node {}", resolved.backend_node_id);
return Ok(());
}
print_output(&result, &global.output)
}
async fn execute_remove(
global: &GlobalOpts,
args: &DomNodeIdArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let resolved = resolve_node(effective, &args.node_id, frame_ctx.as_ref()).await?;
effective
.send_command(
"DOM.removeNode",
Some(serde_json::json!({ "nodeId": resolved.node_id })),
)
.await
.map_err(|e| AppError {
message: format!("Failed to remove node: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let result = RemoveResult {
success: true,
node_id: resolved.backend_node_id,
removed: true,
};
if global.output.plain {
println!("Removed node {}", resolved.backend_node_id);
return Ok(());
}
print_output(&result, &global.output)
}
async fn execute_get_style(
global: &GlobalOpts,
args: &DomGetStyleArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("CSS").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let node_id = resolve_node(effective, &args.node_id, frame_ctx.as_ref())
.await?
.node_id;
let computed = effective
.send_command(
"CSS.getComputedStyleForNode",
Some(serde_json::json!({ "nodeId": node_id })),
)
.await
.map_err(|e| AppError {
message: format!("Failed to get computed style: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let style_entries = computed["computedStyle"]
.as_array()
.ok_or_else(|| AppError {
message: "Failed to get computed style: missing computedStyle array".to_string(),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
if let Some(ref prop) = args.property {
for entry in style_entries {
let name = entry["name"].as_str().unwrap_or("");
if name == prop {
let value = entry["value"].as_str().unwrap_or("").to_string();
let result = StylePropertyResult {
property: prop.clone(),
value: value.clone(),
};
if global.output.plain {
println!("{value}");
return Ok(());
}
return print_output(&result, &global.output);
}
}
return Err(AppError {
message: format!("CSS property '{prop}' not found in computed styles"),
code: ExitCode::GeneralError,
custom_json: None,
});
}
let mut styles = HashMap::new();
for entry in style_entries {
let name = entry["name"].as_str().unwrap_or("").to_string();
let value = entry["value"].as_str().unwrap_or("").to_string();
if !value.is_empty() {
styles.insert(name, value);
}
}
if global.output.plain {
let mut keys: Vec<&String> = styles.keys().collect();
keys.sort();
for k in keys {
println!("{k}: {}", styles[k]);
}
return Ok(());
}
let result = StyleResult { styles };
output::emit(&result, &global.output, "dom get-style", |v| {
summary_of_attributes(&serde_json::to_value(v).unwrap_or_default())
})
}
async fn execute_set_style(
global: &GlobalOpts,
args: &DomSetStyleArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let resolved = resolve_node(effective, &args.node_id, frame_ctx.as_ref()).await?;
effective
.send_command(
"DOM.setAttributeValue",
Some(serde_json::json!({
"nodeId": resolved.node_id,
"name": "style",
"value": args.style,
})),
)
.await
.map_err(|e| AppError {
message: format!("Failed to set style: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let result = SetStyleResult {
success: true,
node_id: resolved.backend_node_id,
style: args.style.clone(),
};
if global.output.plain {
println!("Set style on node {}", resolved.backend_node_id);
return Ok(());
}
print_output(&result, &global.output)
}
async fn execute_parent(
global: &GlobalOpts,
args: &DomNodeIdArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("Runtime").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let node_id = resolve_node(effective, &args.node_id, frame_ctx.as_ref())
.await?
.node_id;
let resolve = effective
.send_command(
"DOM.resolveNode",
Some(serde_json::json!({ "nodeId": node_id })),
)
.await
.map_err(|_| AppError::node_not_found(&args.node_id))?;
let object_id = resolve["object"]["objectId"]
.as_str()
.ok_or_else(|| AppError::node_not_found(&args.node_id))?;
let parent_check = effective
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": "function() { return this.parentElement !== null; }",
"returnByValue": true,
})),
)
.await?;
let has_parent = parent_check["result"]["value"].as_bool().unwrap_or(false);
if !has_parent {
return Err(AppError::no_parent());
}
let parent_obj = effective
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": "function() { return this.parentElement; }",
})),
)
.await?;
let parent_object_id = parent_obj["result"]["objectId"]
.as_str()
.ok_or_else(AppError::no_parent)?;
let parent_node = effective
.send_command(
"DOM.requestNode",
Some(serde_json::json!({ "objectId": parent_object_id })),
)
.await?;
let parent_node_id = parent_node["nodeId"]
.as_i64()
.ok_or_else(AppError::no_parent)?;
let parent = describe_element(effective, parent_node_id).await?;
if global.output.plain {
println!(
"[{}] <{}> \"{}\"",
parent.node_id,
parent.tag,
truncate_text(&parent.text_content, 60)
);
return Ok(());
}
print_output(&parent, &global.output)
}
async fn execute_children(
global: &GlobalOpts,
args: &DomNodeIdArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("Runtime").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let node_id = resolve_node(effective, &args.node_id, frame_ctx.as_ref())
.await?
.node_id;
effective
.send_command(
"DOM.requestChildNodes",
Some(serde_json::json!({ "nodeId": node_id, "depth": 1 })),
)
.await?;
let describe = effective
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "nodeId": node_id, "depth": 1 })),
)
.await
.map_err(|_| AppError::node_not_found(&args.node_id))?;
let children = describe["node"]["children"]
.as_array()
.cloned()
.unwrap_or_default();
let mut elements = Vec::new();
for child in &children {
let node_type = child["nodeType"].as_i64().unwrap_or(0);
if node_type == 1 {
let child_id = child["nodeId"].as_i64().unwrap_or(0);
if child_id > 0 {
if let Ok(el) = describe_element(effective, child_id).await {
elements.push(el);
}
}
}
}
if global.output.plain {
for el in &elements {
println!(
"[{}] <{}> \"{}\"",
el.node_id,
el.tag,
truncate_text(&el.text_content, 60)
);
}
return Ok(());
}
print_output(&elements, &global.output)
}
async fn execute_siblings(
global: &GlobalOpts,
args: &DomNodeIdArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("Runtime").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let node_id = resolve_node(effective, &args.node_id, frame_ctx.as_ref())
.await?
.node_id;
let resolve = effective
.send_command(
"DOM.resolveNode",
Some(serde_json::json!({ "nodeId": node_id })),
)
.await
.map_err(|_| AppError::node_not_found(&args.node_id))?;
let object_id = resolve["object"]["objectId"]
.as_str()
.ok_or_else(|| AppError::node_not_found(&args.node_id))?;
let parent_obj = effective
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": "function() { return this.parentElement; }",
})),
)
.await?;
let parent_object_id = parent_obj["result"]["objectId"]
.as_str()
.ok_or_else(AppError::no_parent)?;
let parent_node = effective
.send_command(
"DOM.requestNode",
Some(serde_json::json!({ "objectId": parent_object_id })),
)
.await?;
let parent_node_id = parent_node["nodeId"]
.as_i64()
.ok_or_else(AppError::no_parent)?;
effective
.send_command(
"DOM.requestChildNodes",
Some(serde_json::json!({ "nodeId": parent_node_id, "depth": 1 })),
)
.await?;
let describe = effective
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "nodeId": parent_node_id, "depth": 1 })),
)
.await
.map_err(|_| AppError::node_not_found(&args.node_id))?;
let children = describe["node"]["children"]
.as_array()
.cloned()
.unwrap_or_default();
let mut elements = Vec::new();
for child in &children {
let node_type = child["nodeType"].as_i64().unwrap_or(0);
let child_id = child["nodeId"].as_i64().unwrap_or(0);
if node_type == 1 && child_id > 0 && child_id != node_id {
if let Ok(el) = describe_element(effective, child_id).await {
elements.push(el);
}
}
}
if global.output.plain {
for el in &elements {
println!(
"[{}] <{}> \"{}\"",
el.node_id,
el.tag,
truncate_text(&el.text_content, 60)
);
}
return Ok(());
}
print_output(&elements, &global.output)
}
#[allow(clippy::too_many_lines)]
async fn execute_events(
global: &GlobalOpts,
args: &DomNodeIdArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let (client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
let mut frame_ctx =
crate::output::resolve_optional_frame(&client, &mut managed, frame, None).await?;
{
let eff_mut = if let Some(ref mut ctx) = frame_ctx {
agentchrome::frame::frame_session_mut(ctx, &mut managed)
} else {
&mut managed
};
eff_mut.ensure_domain("DOM").await?;
eff_mut.ensure_domain("Runtime").await?;
}
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let resolved = resolve_node(effective, &args.node_id, frame_ctx.as_ref()).await?;
let resolve = effective
.send_command(
"DOM.resolveNode",
Some(serde_json::json!({ "nodeId": resolved.node_id })),
)
.await
.map_err(|_| AppError::node_not_found(&args.node_id))?;
let object_id = resolve["object"]["objectId"]
.as_str()
.ok_or_else(|| AppError::node_not_found(&args.node_id))?;
let response = effective
.send_command(
"DOMDebugger.getEventListeners",
Some(serde_json::json!({ "objectId": object_id })),
)
.await
.map_err(|e| AppError {
message: format!("DOMDebugger.getEventListeners failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?;
let raw_listeners = response["listeners"]
.as_array()
.cloned()
.unwrap_or_default();
let mut handler_descriptions: HashMap<String, String> = HashMap::new();
let has_missing = raw_listeners.iter().any(|l| {
l["handler"]["description"]
.as_str()
.unwrap_or("")
.is_empty()
});
if has_missing {
let _ = effective
.send_command(
"Runtime.callFunctionOn",
Some(serde_json::json!({
"objectId": object_id,
"functionDeclaration": "function() { window.__ac_tmp = this; }",
})),
)
.await;
let js = r"
(function() {
var result = {};
try {
var listeners = getEventListeners(window.__ac_tmp);
for (var type in listeners) {
var arr = listeners[type];
for (var i = 0; i < arr.length; i++) {
var key = type + ':' + arr[i].useCapture + ':' + arr[i].once + ':' + arr[i].passive;
result[key] = arr[i].listener.toString();
}
}
} catch(e) {}
delete window.__ac_tmp;
return JSON.stringify(result);
})()
";
let mut eval_params = serde_json::json!({
"expression": js,
"returnByValue": true,
"includeCommandLineAPI": true,
});
if let Some(ctx_id) = frame_ctx
.as_ref()
.and_then(agentchrome::frame::execution_context_id)
{
eval_params["contextId"] = serde_json::json!(ctx_id);
}
if let Ok(eval) = effective
.send_command("Runtime.evaluate", Some(eval_params))
.await
{
if let Some(json_str) = eval["result"]["value"].as_str() {
if let Ok(map) = serde_json::from_str::<HashMap<String, String>>(json_str) {
handler_descriptions = map;
}
}
}
}
let listeners: Vec<EventListenerInfo> = raw_listeners
.iter()
.map(|l| {
let event_type = l["type"].as_str().unwrap_or("").to_string();
let use_capture = l["useCapture"].as_bool().unwrap_or(false);
let once = l["once"].as_bool().unwrap_or(false);
let passive = l["passive"].as_bool().unwrap_or(false);
let mut description = l["handler"]["description"]
.as_str()
.unwrap_or("")
.to_string();
if description.is_empty() {
let key = format!("{event_type}:{use_capture}:{once}:{passive}");
if let Some(desc) = handler_descriptions.get(&key) {
description.clone_from(desc);
}
}
EventListenerInfo {
event_type,
use_capture,
once,
passive,
handler: EventHandler {
description,
script_id: l["scriptId"].as_str().map(String::from),
line_number: l["lineNumber"].as_i64(),
column_number: l["columnNumber"].as_i64(),
},
}
})
.collect();
let result = EventListenersResult { listeners };
if global.output.plain {
for listener in &result.listeners {
println!(
"{} capture:{} once:{} passive:{} handler:{}",
listener.event_type,
listener.use_capture,
listener.once,
listener.passive,
listener.handler.description
);
}
return Ok(());
}
output::emit(&result, &global.output, "dom events", |v| {
summary_of_events(&serde_json::to_value(v).unwrap_or_default())
})
}
const TREE_TEXT_MAX: usize = 60;
async fn execute_tree(global: &GlobalOpts, args: &DomTreeArgs) -> Result<(), AppError> {
let (_client, mut managed) = setup_session(global).await?;
if global.auto_dismiss_dialogs {
let _dismiss = managed.spawn_auto_dismiss().await?;
}
managed.ensure_domain("DOM").await?;
let depth = args.depth.map_or(-1, i64::from);
let target = args.root_positional.as_ref().or(args.root.as_ref());
let root_node = if let Some(root_target) = target {
let node_id = resolve_node(&managed, root_target, None).await?.node_id;
managed
.send_command(
"DOM.describeNode",
Some(serde_json::json!({ "nodeId": node_id, "depth": depth })),
)
.await
.map_err(|_| AppError::node_not_found(root_target))?
} else {
managed
.send_command(
"DOM.getDocument",
Some(serde_json::json!({ "depth": depth })),
)
.await
.map_err(|e| AppError {
message: format!("DOM.getDocument failed: {e}"),
code: ExitCode::ProtocolError,
custom_json: None,
})?
};
let node = if target.is_some() {
&root_node["node"]
} else {
&root_node["root"]
};
let mut output = String::new();
format_tree_node(&mut output, node, 0, args.depth);
if global.output.plain || (!global.output.json && !global.output.pretty) {
print!("{output}");
return Ok(());
}
print_output(&TreeOutput { tree: output }, &global.output)
}
fn format_tree_node(
out: &mut String,
node: &serde_json::Value,
indent: usize,
max_depth: Option<u32>,
) {
let node_type = node["nodeType"].as_i64().unwrap_or(0);
let node_name = node["nodeName"].as_str().unwrap_or("");
match node_type {
1 => {
let tag = node_name.to_lowercase();
let indent_str = " ".repeat(indent);
let mut attr_hints = Vec::new();
if let Some(attrs) = node["attributes"].as_array() {
let mut i = 0;
while i + 1 < attrs.len() {
let name = attrs[i].as_str().unwrap_or("");
if matches!(name, "id" | "class" | "href" | "src" | "type" | "name") {
attr_hints.push(format!("[{name}]"));
}
i += 2;
}
}
let attr_str = attr_hints.join("");
let text = extract_direct_text(node);
let text_str = if text.is_empty() {
String::new()
} else {
format!(" \"{}\"", truncate_text(&text, TREE_TEXT_MAX))
};
let _ = writeln!(out, "{indent_str}{tag}{attr_str}{text_str}");
}
3 => {
return;
}
9 => {
}
_ => return,
}
if let Some(max) = max_depth {
#[allow(clippy::cast_possible_truncation)]
if indent as u32 >= max {
if node["children"]
.as_array()
.is_some_and(|c| c.iter().any(|ch| ch["nodeType"].as_i64() == Some(1)))
{
let child_indent = " ".repeat(indent + 1);
let _ = writeln!(out, "{child_indent}...");
}
return;
}
}
if let Some(children) = node["children"].as_array() {
let child_indent = if node_type == 9 { indent } else { indent + 1 };
for child in children {
format_tree_node(out, child, child_indent, max_depth);
}
}
}
fn extract_direct_text(node: &serde_json::Value) -> String {
let mut text = String::new();
if let Some(children) = node["children"].as_array() {
for child in children {
if child["nodeType"].as_i64() == Some(3) {
if let Some(value) = child["nodeValue"].as_str() {
let trimmed = value.trim();
if !trimmed.is_empty() {
if !text.is_empty() {
text.push(' ');
}
text.push_str(trimmed);
}
}
}
}
}
text
}
fn truncate_text(text: &str, max: usize) -> String {
let trimmed = text.trim().replace('\n', " ");
if trimmed.chars().count() > max {
let end = trimmed
.char_indices()
.nth(max)
.map_or(trimmed.len(), |(i, _)| i);
format!("{}...", &trimmed[..end])
} else {
trimmed
}
}
pub async fn run_from_session(
_managed: &mut ManagedSession,
global: &GlobalOpts,
args: &DomArgs,
) -> Result<serde_json::Value, AppError> {
execute_dom(global, args).await?;
Ok(serde_json::json!({"executed": true}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_uid_valid() {
assert!(snapshot::is_uid("s1"));
assert!(snapshot::is_uid("s42"));
assert!(snapshot::is_uid("s999"));
}
#[test]
fn is_uid_invalid() {
assert!(!snapshot::is_uid("s"));
assert!(!snapshot::is_uid("s0a"));
assert!(!snapshot::is_uid("css:button"));
assert!(!snapshot::is_uid("button"));
assert!(!snapshot::is_uid("42"));
}
#[test]
fn is_css_selector_valid() {
assert!(snapshot::is_css_selector("css:#button"));
assert!(snapshot::is_css_selector("css:.class"));
}
#[test]
fn is_css_selector_invalid() {
assert!(!snapshot::is_css_selector("#button"));
assert!(!snapshot::is_css_selector("s1"));
}
#[test]
fn truncate_text_short() {
assert_eq!(truncate_text("Hello", 10), "Hello");
}
#[test]
fn truncate_text_long() {
let long = "a".repeat(100);
let result = truncate_text(&long, 10);
assert_eq!(result.len(), 13); assert!(result.ends_with("..."));
}
#[test]
fn truncate_text_with_newlines() {
assert_eq!(truncate_text("hello\nworld", 20), "hello world");
}
#[test]
fn truncate_text_multibyte_utf8() {
let text = "🎉🎊🎈🎁🎂🎃🎄🎅🎆🎇🎋🎍";
let result = truncate_text(text, 5);
assert!(result.ends_with("..."));
assert_eq!(result.chars().count(), 8);
}
#[test]
fn extract_direct_text_basic() {
let node = serde_json::json!({
"children": [
{ "nodeType": 3, "nodeValue": "Hello World" }
]
});
assert_eq!(extract_direct_text(&node), "Hello World");
}
#[test]
fn extract_direct_text_multiple() {
let node = serde_json::json!({
"children": [
{ "nodeType": 3, "nodeValue": "Hello" },
{ "nodeType": 1, "nodeName": "SPAN" },
{ "nodeType": 3, "nodeValue": "World" }
]
});
assert_eq!(extract_direct_text(&node), "Hello World");
}
#[test]
fn extract_direct_text_empty() {
let node = serde_json::json!({
"children": [
{ "nodeType": 1, "nodeName": "SPAN" }
]
});
assert_eq!(extract_direct_text(&node), "");
}
#[test]
fn format_tree_simple() {
let doc = serde_json::json!({
"nodeType": 9,
"nodeName": "#document",
"children": [{
"nodeType": 1,
"nodeName": "HTML",
"children": [{
"nodeType": 1,
"nodeName": "BODY",
"children": [{
"nodeType": 1,
"nodeName": "H1",
"children": [{
"nodeType": 3,
"nodeValue": "Hello"
}]
}]
}]
}]
});
let mut out = String::new();
format_tree_node(&mut out, &doc, 0, None);
assert!(out.contains("html"));
assert!(out.contains(" body"));
assert!(out.contains(" h1 \"Hello\""));
}
#[test]
fn format_tree_with_depth_limit() {
let doc = serde_json::json!({
"nodeType": 9,
"nodeName": "#document",
"children": [{
"nodeType": 1,
"nodeName": "HTML",
"children": [{
"nodeType": 1,
"nodeName": "BODY",
"children": [{
"nodeType": 1,
"nodeName": "H1",
"children": [{
"nodeType": 3,
"nodeValue": "Hello"
}]
}]
}]
}]
});
let mut out = String::new();
format_tree_node(&mut out, &doc, 0, Some(1));
assert!(out.contains("html"));
assert!(out.contains(" body"));
assert!(out.contains("..."));
assert!(!out.contains("h1"));
}
#[test]
fn format_tree_with_attributes() {
let node = serde_json::json!({
"nodeType": 1,
"nodeName": "A",
"attributes": ["href", "https://example.com", "class", "link"],
"children": [{
"nodeType": 3,
"nodeValue": "Click me"
}]
});
let mut out = String::new();
format_tree_node(&mut out, &node, 0, None);
assert!(out.contains("a[href][class]"));
assert!(out.contains("\"Click me\""));
}
#[test]
fn dom_element_serialization() {
let el = DomElement {
node_id: 42,
tag: "h1".to_string(),
attributes: HashMap::from([("class".to_string(), "title".to_string())]),
text_content: "Hello".to_string(),
};
let json: serde_json::Value = serde_json::to_value(&el).unwrap();
assert_eq!(json["nodeId"], 42);
assert_eq!(json["tag"], "h1");
assert_eq!(json["attributes"]["class"], "title");
assert_eq!(json["textContent"], "Hello");
}
#[test]
fn attribute_result_serialization() {
let result = AttributeResult {
attribute: "href".to_string(),
value: "https://example.com".to_string(),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["attribute"], "href");
assert_eq!(json["value"], "https://example.com");
}
#[test]
fn text_result_serialization() {
let result = TextResult {
text_content: "Hello World".to_string(),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["textContent"], "Hello World");
}
#[test]
fn html_result_serialization() {
let result = HtmlResult {
outer_html: "<h1>Hello</h1>".to_string(),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["outerHTML"], "<h1>Hello</h1>");
}
#[test]
fn mutation_result_serialization_with_attribute() {
let result = MutationResult {
success: true,
node_id: 42,
attribute: Some("class".to_string()),
value: Some("highlight".to_string()),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["success"], true);
assert_eq!(json["nodeId"], 42);
assert_eq!(json["attribute"], "class");
assert_eq!(json["value"], "highlight");
}
#[test]
fn mutation_result_serialization_without_attribute() {
let result = MutationResult {
success: true,
node_id: 42,
attribute: None,
value: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["success"], true);
assert_eq!(json["nodeId"], 42);
assert!(json.get("attribute").is_none());
assert!(json.get("value").is_none());
}
#[test]
fn style_result_serialization() {
let result = StyleResult {
styles: HashMap::from([
("display".to_string(), "block".to_string()),
("color".to_string(), "rgb(0, 0, 0)".to_string()),
]),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["styles"]["display"], "block");
assert_eq!(json["styles"]["color"], "rgb(0, 0, 0)");
}
#[test]
fn style_property_result_serialization() {
let result = StylePropertyResult {
property: "display".to_string(),
value: "block".to_string(),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["property"], "display");
assert_eq!(json["value"], "block");
}
#[test]
fn event_handler_serialization() {
let handler = EventHandler {
description: "function handleClick() { ... }".to_string(),
script_id: Some("42".to_string()),
line_number: Some(10),
column_number: Some(0),
};
let json: serde_json::Value = serde_json::to_value(&handler).unwrap();
assert_eq!(json["description"], "function handleClick() { ... }");
assert_eq!(json["scriptId"], "42");
assert_eq!(json["lineNumber"], 10);
assert_eq!(json["columnNumber"], 0);
}
#[test]
fn event_handler_serialization_null_fields() {
let handler = EventHandler {
description: "function() {}".to_string(),
script_id: None,
line_number: None,
column_number: None,
};
let json: serde_json::Value = serde_json::to_value(&handler).unwrap();
assert_eq!(json["description"], "function() {}");
assert!(json["scriptId"].is_null());
assert!(json["lineNumber"].is_null());
assert!(json["columnNumber"].is_null());
}
#[test]
fn event_listener_info_serialization() {
let info = EventListenerInfo {
event_type: "click".to_string(),
use_capture: false,
once: true,
passive: false,
handler: EventHandler {
description: "function handleClick() {}".to_string(),
script_id: Some("42".to_string()),
line_number: Some(10),
column_number: Some(0),
},
};
let json: serde_json::Value = serde_json::to_value(&info).unwrap();
assert_eq!(json["type"], "click");
assert_eq!(json["useCapture"], false);
assert_eq!(json["once"], true);
assert_eq!(json["passive"], false);
assert_eq!(json["handler"]["description"], "function handleClick() {}");
assert_eq!(json["handler"]["scriptId"], "42");
}
#[test]
fn event_listeners_result_serialization_with_listeners() {
let result = EventListenersResult {
listeners: vec![EventListenerInfo {
event_type: "click".to_string(),
use_capture: false,
once: false,
passive: false,
handler: EventHandler {
description: "function() {}".to_string(),
script_id: None,
line_number: None,
column_number: None,
},
}],
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
let listeners = json["listeners"].as_array().unwrap();
assert_eq!(listeners.len(), 1);
assert_eq!(listeners[0]["type"], "click");
}
#[test]
fn event_listeners_result_serialization_empty() {
let result = EventListenersResult { listeners: vec![] };
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
let listeners = json["listeners"].as_array().unwrap();
assert!(listeners.is_empty());
}
#[test]
fn summary_of_select_happy_path() {
let value = serde_json::json!([
{"nodeId": 1, "tag": "button", "attributes": {}, "textContent": "Click me"},
{"nodeId": 2, "tag": "a", "attributes": {}, "textContent": "Link"},
]);
let summary = summary_of_select(&value);
assert_eq!(summary["match_count"], 2);
assert_eq!(summary["first_match"]["tag"], "button");
assert_eq!(summary["first_match"]["uid"], 1);
assert!(summary["first_match"]["role"].is_null());
}
#[test]
fn summary_of_select_empty_array() {
let value = serde_json::json!([]);
let summary = summary_of_select(&value);
assert_eq!(summary["match_count"], 0);
assert!(summary["first_match"].is_null());
}
#[test]
fn summary_of_select_non_array_returns_null() {
let summary = summary_of_select(&serde_json::json!({"not": "array"}));
assert!(summary["match_count"].is_null());
assert!(summary["first_match"].is_null());
}
#[test]
fn summary_of_attributes_happy_path() {
let value = serde_json::json!({
"styles": {
"color": "red",
"font-size": "16px",
"margin": "0px",
}
});
let summary = summary_of_attributes(&value);
assert_eq!(summary["attribute_count"], 3);
let keys = summary["keys_seen"].as_array().unwrap();
assert_eq!(keys.len(), 3);
}
#[test]
fn summary_of_attributes_missing_styles_key_returns_null() {
let summary = summary_of_attributes(&serde_json::json!({"other": "data"}));
assert!(summary["attribute_count"].is_null());
assert!(summary["keys_seen"].is_null());
}
#[test]
fn summary_of_events_happy_path() {
let value = serde_json::json!({
"listeners": [
{"type": "click", "useCapture": false, "once": false, "passive": false,
"handler": {"description": "onclick", "scriptId": null, "lineNumber": null, "columnNumber": null}},
{"type": "mouseover", "useCapture": false, "once": false, "passive": false,
"handler": {"description": "onmouseover", "scriptId": null, "lineNumber": null, "columnNumber": null}},
{"type": "click", "useCapture": true, "once": false, "passive": false,
"handler": {"description": "onclick2", "scriptId": null, "lineNumber": null, "columnNumber": null}},
]
});
let summary = summary_of_events(&value);
assert_eq!(summary["listener_count"], 3);
let event_types = summary["event_types"].as_array().unwrap();
assert_eq!(event_types.len(), 2);
}
#[test]
fn summary_of_events_empty_returns_zero() {
let value = serde_json::json!({"listeners": []});
let summary = summary_of_events(&value);
assert_eq!(summary["listener_count"], 0);
let types = summary["event_types"].as_array().unwrap();
assert!(types.is_empty());
}
#[test]
fn summary_of_events_missing_listeners_returns_null() {
let summary = summary_of_events(&serde_json::json!({"other": "data"}));
assert!(summary["listener_count"].is_null());
assert!(summary["event_types"].is_null());
}
}