use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use agentchrome::connection::ManagedSession;
use agentchrome::error::{AppError, ExitCode};
use crate::cli::{
FormArgs, FormClearArgs, FormCommand, FormFillArgs, FormFillManyArgs, FormSubmitArgs,
FormUploadArgs, GlobalOpts,
};
use crate::output::{self, print_output, setup_session};
use crate::snapshot;
#[derive(Serialize)]
struct FillResult {
filled: String,
value: String,
#[serde(skip_serializing_if = "Option::is_none")]
snapshot: Option<serde_json::Value>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum FillManyOutput {
Plain(Vec<FillResult>),
WithSnapshot {
results: Vec<FillResult>,
snapshot: serde_json::Value,
},
}
#[derive(Serialize)]
struct ClearResult {
cleared: String,
#[serde(skip_serializing_if = "Option::is_none")]
snapshot: Option<serde_json::Value>,
}
#[derive(Serialize)]
struct UploadResult {
uploaded: String,
files: Vec<String>,
size: u64,
#[serde(skip_serializing_if = "Option::is_none")]
snapshot: Option<serde_json::Value>,
}
#[derive(Serialize)]
struct SubmitResult {
submitted: String,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
snapshot: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct FillEntry {
#[serde(alias = "uid")]
target: String,
value: String,
}
fn print_fill_plain(result: &FillResult) {
println!("Filled {} = {}", result.filled, result.value);
}
fn print_fill_many_plain(results: &[FillResult]) {
for r in results {
println!("Filled {} = {}", r.filled, r.value);
}
}
fn print_clear_plain(result: &ClearResult) {
println!("Cleared {}", result.cleared);
}
fn print_upload_plain(result: &UploadResult) {
let file_list = result.files.join(", ");
println!(
"Uploaded {} ({} bytes): {}",
result.uploaded, result.size, file_list
);
}
fn print_submit_plain(result: &SubmitResult) {
if let Some(url) = &result.url {
println!("Submitted {} → {}", result.submitted, url);
} else {
println!("Submitted {}", result.submitted);
}
}
async fn resolve_target_to_backend_node_id(
session: &ManagedSession,
target: &str,
) -> Result<i64, AppError> {
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))?;
Ok(backend_node_id)
} else if snapshot::is_css_selector(target) {
let selector = &target[4..];
let doc_response = session.send_command("DOM.getDocument", None).await?;
let root_node_id = doc_response["root"]["nodeId"]
.as_i64()
.ok_or_else(|| AppError::element_not_found(selector))?;
let query_params = serde_json::json!({
"nodeId": root_node_id,
"selector": selector,
});
let query_response = session
.send_command("DOM.querySelector", Some(query_params))
.await?;
let node_id = query_response["nodeId"].as_i64().unwrap_or(0);
if node_id == 0 {
return Err(AppError::element_not_found(selector));
}
let describe_params = serde_json::json!({ "nodeId": node_id });
let describe_response = session
.send_command("DOM.describeNode", Some(describe_params))
.await?;
let backend_node_id = describe_response["node"]["backendNodeId"]
.as_i64()
.ok_or_else(|| AppError::element_not_found(selector))?;
Ok(backend_node_id)
} else {
Err(AppError::element_not_found(target))
}
}
async fn take_snapshot(
session: &mut ManagedSession,
url: &str,
compact: bool,
) -> Result<serde_json::Value, AppError> {
session.ensure_domain("Accessibility").await?;
let response = session
.send_command("Accessibility.getFullAXTree", None)
.await?;
let nodes = response["nodes"]
.as_array()
.ok_or_else(|| AppError::snapshot_failed("missing nodes array"))?;
let build_result = snapshot::build_tree(nodes, false);
let state = snapshot::SnapshotState {
url: url.to_string(),
timestamp: agentchrome::session::now_iso8601(),
uid_map: build_result.uid_map,
frame_index: None,
frame_id: None,
aggregate: false,
frame_uid_ranges: Vec::new(),
frame_ids: Vec::new(),
};
snapshot::write_snapshot_state(&state)?;
let root = if compact {
snapshot::compact_tree(&build_result.root)
} else {
build_result.root
};
let snapshot_json = serde_json::to_value(&root)
.map_err(|e| AppError::snapshot_failed(&format!("failed to serialize snapshot: {e}")))?;
Ok(snapshot_json)
}
const FILL_JS: &str = r"
function(value) {
const el = this;
const tag = el.tagName.toLowerCase();
if (tag === 'select') {
const options = Array.from(el.options);
const idx = options.findIndex(o => o.value === value || o.textContent.trim() === value);
if (idx >= 0) {
el.selectedIndex = idx;
el.value = options[idx].value;
}
} else if (el.type === 'checkbox' || el.type === 'radio') {
el.checked = value === 'true' || value === 'checked';
} else {
// text, password, email, number, textarea, date, tel, url, etc.
const proto = tag === 'textarea'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(el, value);
} else {
el.value = value;
}
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
";
const CLEAR_ACTIVE_ELEMENT_JS: &str = "(function(){\
var el=document.activeElement;\
var proto=el.tagName==='TEXTAREA'\
?window.HTMLTextAreaElement.prototype\
:window.HTMLInputElement.prototype;\
Object.getOwnPropertyDescriptor(proto,'value').set.call(el,'');\
el.dispatchEvent(new InputEvent('input',{bubbles:true,cancelable:true,inputType:'deleteContentBackward'}));\
})()";
const CLEAR_JS: &str = r"
function() {
const el = this;
const tag = el.tagName.toLowerCase();
if (el.type === 'checkbox' || el.type === 'radio') {
el.checked = false;
} else if (tag === 'select') {
el.selectedIndex = 0;
} else {
const proto = tag === 'textarea'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (nativeInputValueSetter) {
nativeInputValueSetter.call(el, '');
} else {
el.value = '';
}
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
";
const FIND_FORM_JS: &str = r"
function() {
if (this.tagName && this.tagName.toLowerCase() === 'form') {
return this;
}
const form = this.closest('form');
if (form) {
return form;
}
throw new Error('NOT_IN_FORM');
}
";
const SUBMIT_JS: &str = r"
function() {
this.requestSubmit();
}
";
async fn resolve_to_object_id(session: &ManagedSession, target: &str) -> Result<String, AppError> {
let backend_node_id = resolve_target_to_backend_node_id(session, target).await?;
let resolve_params = serde_json::json!({ "backendNodeId": backend_node_id });
let resolve_response = session
.send_command("DOM.resolveNode", Some(resolve_params))
.await
.map_err(|e| AppError::interaction_failed("resolve_node", &e.to_string()))?;
resolve_response["object"]["objectId"]
.as_str()
.map(String::from)
.ok_or_else(|| AppError::interaction_failed("resolve_node", "no objectId returned"))
}
async fn describe_element(
session: &ManagedSession,
backend_node_id: i64,
) -> Result<(String, Option<String>, Option<String>), AppError> {
let params = serde_json::json!({ "backendNodeId": backend_node_id });
let response = session
.send_command("DOM.describeNode", Some(params))
.await
.map_err(|e| AppError::interaction_failed("describe_node", &e.to_string()))?;
let node_name = response["node"]["nodeName"]
.as_str()
.unwrap_or("")
.to_lowercase();
let (input_type, role) =
response["node"]["attributes"]
.as_array()
.map_or((None, None), |attrs| {
let mut input_type = None;
let mut role = None;
for pair in attrs.chunks(2) {
match pair.first().and_then(|v| v.as_str()) {
Some("type") => {
input_type = pair.get(1).and_then(|v| v.as_str()).map(String::from);
}
Some("role") => {
role = pair.get(1).and_then(|v| v.as_str()).map(String::from);
}
_ => {}
}
}
(input_type, role)
});
Ok((node_name, input_type, role))
}
fn is_text_input(node_name: &str, input_type: Option<&str>) -> bool {
if node_name == "textarea" {
return true;
}
if node_name == "input" {
return matches!(
input_type,
None | Some("text" | "password" | "email" | "number" | "tel" | "url" | "search")
);
}
false
}
fn is_fillable_via_js(node_name: &str, input_type: Option<&str>) -> bool {
if node_name == "select" {
return true;
}
if node_name == "input" {
return matches!(input_type, Some("checkbox" | "radio"));
}
false
}
async fn fill_element_keyboard(
session: &ManagedSession,
backend_node_id: i64,
value: &str,
) -> Result<(), AppError> {
let focus_params = serde_json::json!({ "backendNodeId": backend_node_id });
session
.send_command("DOM.focus", Some(focus_params))
.await
.map_err(|e| AppError::interaction_failed("focus", &e.to_string()))?;
session
.send_command(
"Runtime.evaluate",
Some(serde_json::json!({ "expression": "document.activeElement.select()" })),
)
.await
.map_err(|e| AppError::interaction_failed("select_all", &e.to_string()))?;
for ch in value.chars() {
let params = serde_json::json!({
"type": "char",
"text": ch.to_string(),
});
session
.send_command("Input.dispatchKeyEvent", Some(params))
.await
.map_err(|e| AppError::interaction_failed("char", &e.to_string()))?;
}
Ok(())
}
const COMBOBOX_LISTBOX_VISIBLE_JS: &str = r#"(function(){
var cb = document.querySelector('[role="combobox"][aria-expanded="true"]');
if (!cb) return false;
var listboxId = cb.getAttribute('aria-owns') || cb.getAttribute('aria-controls');
var listbox = listboxId ? document.getElementById(listboxId) : document.querySelector('[role="listbox"]');
if (!listbox) return false;
var options = listbox.querySelectorAll('[role="option"]');
return options.length > 0;
})()"#;
async fn fill_element_combobox(
session: &ManagedSession,
backend_node_id: i64,
object_id: &str,
value: &str,
confirm_key: &str,
) -> Result<(), AppError> {
let focus_params = serde_json::json!({ "backendNodeId": backend_node_id });
session
.send_command("DOM.focus", Some(focus_params))
.await
.map_err(|e| AppError::interaction_failed("combobox_focus", &e.to_string()))?;
let click_params = serde_json::json!({
"objectId": object_id,
"functionDeclaration": "function() { this.click(); }",
"arguments": [],
});
session
.send_command("Runtime.callFunctionOn", Some(click_params))
.await
.map_err(|e| AppError::interaction_failed("combobox_click", &e.to_string()))?;
tokio::time::sleep(Duration::from_millis(50)).await;
for ch in value.chars() {
let params = serde_json::json!({
"type": "char",
"text": ch.to_string(),
});
session
.send_command("Input.dispatchKeyEvent", Some(params))
.await
.map_err(|e| AppError::interaction_failed("combobox_type", &e.to_string()))?;
}
let mut listbox_visible = false;
for _ in 0..30 {
tokio::time::sleep(Duration::from_millis(100)).await;
let poll_result = session
.send_command(
"Runtime.evaluate",
Some(serde_json::json!({ "expression": COMBOBOX_LISTBOX_VISIBLE_JS })),
)
.await
.map_err(|e| AppError::interaction_failed("combobox_poll", &e.to_string()))?;
if poll_result["result"]["value"].as_bool().unwrap_or(false) {
listbox_visible = true;
break;
}
}
if !listbox_visible {
return Err(AppError {
message: format!("No matching option found in combobox for value: {value}"),
code: ExitCode::GeneralError,
custom_json: None,
});
}
session
.send_command(
"Input.dispatchKeyEvent",
Some(serde_json::json!({
"type": "keyDown",
"key": confirm_key,
"code": confirm_key,
})),
)
.await
.map_err(|e| AppError::interaction_failed("combobox_confirm", &e.to_string()))?;
session
.send_command(
"Input.dispatchKeyEvent",
Some(serde_json::json!({
"type": "keyUp",
"key": confirm_key,
"code": confirm_key,
})),
)
.await
.map_err(|e| AppError::interaction_failed("combobox_confirm", &e.to_string()))?;
Ok(())
}
async fn clear_element_keyboard(
session: &ManagedSession,
backend_node_id: i64,
) -> Result<(), AppError> {
let focus_params = serde_json::json!({ "backendNodeId": backend_node_id });
session
.send_command("DOM.focus", Some(focus_params))
.await
.map_err(|e| AppError::interaction_failed("focus", &e.to_string()))?;
session
.send_command(
"Runtime.evaluate",
Some(serde_json::json!({ "expression": CLEAR_ACTIVE_ELEMENT_JS })),
)
.await
.map_err(|e| AppError::interaction_failed("clear", &e.to_string()))?;
Ok(())
}
async fn fill_element(
session: &ManagedSession,
target: &str,
value: &str,
confirm_key: Option<&str>,
) -> Result<(), AppError> {
let backend_node_id = resolve_target_to_backend_node_id(session, target).await?;
let (node_name, input_type, role) = describe_element(session, backend_node_id).await?;
if role.as_deref() == Some("combobox") {
let object_id = resolve_to_object_id(session, target).await?;
fill_element_combobox(
session,
backend_node_id,
&object_id,
value,
confirm_key.unwrap_or("Enter"),
)
.await
} else if is_text_input(&node_name, input_type.as_deref()) {
fill_element_keyboard(session, backend_node_id, value).await
} else if is_fillable_via_js(&node_name, input_type.as_deref()) {
let object_id = resolve_to_object_id(session, target).await?;
let call_params = serde_json::json!({
"objectId": object_id,
"functionDeclaration": FILL_JS,
"arguments": [{ "value": value }],
});
session
.send_command("Runtime.callFunctionOn", Some(call_params))
.await
.map_err(|e| AppError::interaction_failed("fill", &e.to_string()))?;
Ok(())
} else {
Err(AppError::form_fill_not_fillable(
target,
&node_name,
role.as_deref(),
))
}
}
async fn clear_element(session: &ManagedSession, target: &str) -> Result<(), AppError> {
let backend_node_id = resolve_target_to_backend_node_id(session, target).await?;
let (node_name, input_type, role) = describe_element(session, backend_node_id).await?;
if is_text_input(&node_name, input_type.as_deref()) {
clear_element_keyboard(session, backend_node_id).await
} else if is_fillable_via_js(&node_name, input_type.as_deref()) {
let object_id = resolve_to_object_id(session, target).await?;
let call_params = serde_json::json!({
"objectId": object_id,
"functionDeclaration": CLEAR_JS,
"arguments": [],
});
session
.send_command("Runtime.callFunctionOn", Some(call_params))
.await
.map_err(|e| AppError::interaction_failed("clear", &e.to_string()))?;
Ok(())
} else {
Err(AppError::form_fill_not_fillable(
target,
&node_name,
role.as_deref(),
))
}
}
async fn get_current_url(session: &ManagedSession) -> Result<String, AppError> {
let url_response = session
.send_command(
"Runtime.evaluate",
Some(serde_json::json!({ "expression": "window.location.href" })),
)
.await?;
Ok(url_response["result"]["value"]
.as_str()
.unwrap_or("")
.to_string())
}
async fn execute_fill(
global: &GlobalOpts,
args: &FormFillArgs,
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, Some(&args.target))
.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
};
fill_element(
effective,
&args.target,
&args.value,
args.confirm_key.as_deref(),
)
.await?;
let snapshot = if args.include_snapshot {
let url = get_current_url(&managed).await?;
Some(take_snapshot(&mut managed, &url, args.compact).await?)
} else {
None
};
let result = FillResult {
filled: args.target.clone(),
value: args.value.clone(),
snapshot,
};
if global.output.plain {
print_fill_plain(&result);
Ok(())
} else {
output::emit_with_snapshot(
&result,
&global.output,
"form fill",
"snapshot",
crate::snapshot::summary_of_snapshot,
)
}
}
async fn execute_fill_many(
global: &GlobalOpts,
args: &FormFillManyArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let json_str = if let Some(file_path) = &args.file {
read_json_file(file_path)?
} else if let Some(json) = &args.input {
json.clone()
} else {
return Err(AppError {
message: "Either inline JSON or --file must be provided".to_string(),
code: ExitCode::GeneralError,
custom_json: None,
});
};
let entries: Vec<FillEntry> = serde_json::from_str(&json_str).map_err(|e| AppError {
message: format!("Invalid JSON: expected array of {{target, value}} objects: {e}"),
code: ExitCode::GeneralError,
custom_json: None,
})?;
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 mut results = Vec::with_capacity(entries.len());
for entry in &entries {
fill_element(effective, &entry.target, &entry.value, None).await?;
results.push(FillResult {
filled: entry.target.clone(),
value: entry.value.clone(),
snapshot: None,
});
}
if args.include_snapshot {
let url = get_current_url(&managed).await?;
let snapshot = take_snapshot(&mut managed, &url, args.compact).await?;
let output = FillManyOutput::WithSnapshot { results, snapshot };
if global.output.plain {
if let FillManyOutput::WithSnapshot { results, .. } = &output {
print_fill_many_plain(results);
}
Ok(())
} else {
output::emit_with_snapshot(
&output,
&global.output,
"form fill-many",
"snapshot",
crate::snapshot::summary_of_snapshot,
)
}
} else {
let output = FillManyOutput::Plain(results);
if global.output.plain {
if let FillManyOutput::Plain(results) = &output {
print_fill_many_plain(results);
}
Ok(())
} else {
print_output(&output, &global.output)
}
}
}
async fn execute_clear(
global: &GlobalOpts,
args: &FormClearArgs,
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, Some(&args.target))
.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
};
clear_element(effective, &args.target).await?;
let snapshot = if args.include_snapshot {
let url = get_current_url(&managed).await?;
Some(take_snapshot(&mut managed, &url, args.compact).await?)
} else {
None
};
let result = ClearResult {
cleared: args.target.clone(),
snapshot,
};
if global.output.plain {
print_clear_plain(&result);
Ok(())
} else {
output::emit_with_snapshot(
&result,
&global.output,
"form clear",
"snapshot",
crate::snapshot::summary_of_snapshot,
)
}
}
const IS_FILE_INPUT_JS: &str = r"
function() {
return this.tagName === 'INPUT' && this.type === 'file';
}
";
const DISPATCH_CHANGE_JS: &str = r"
function() {
this.dispatchEvent(new Event('change', { bubbles: true }));
}
";
const LARGE_FILE_THRESHOLD: u64 = 100 * 1024 * 1024;
#[allow(clippy::too_many_lines)]
async fn execute_upload(
global: &GlobalOpts,
args: &FormUploadArgs,
frame: Option<&str>,
) -> Result<(), AppError> {
let mut total_size: u64 = 0;
let mut resolved_paths: Vec<String> = Vec::with_capacity(args.files.len());
for path in &args.files {
let metadata = std::fs::metadata(path)
.map_err(|_| AppError::file_not_found(&path.display().to_string()))?;
if !metadata.is_file() {
return Err(AppError::file_not_found(&path.display().to_string()));
}
let file_size = metadata.len();
if file_size > LARGE_FILE_THRESHOLD {
eprintln!(
"warning: file is large ({} bytes): {}",
file_size,
path.display()
);
}
total_size += file_size;
let canonical = path
.canonicalize()
.map_err(|_| AppError::file_not_readable(&path.display().to_string()))?;
resolved_paths.push(canonical.to_string_lossy().to_string());
}
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, Some(&args.target))
.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 backend_node_id = resolve_target_to_backend_node_id(effective, &args.target).await?;
let object_id = resolve_to_object_id(effective, &args.target).await?;
let check_params = serde_json::json!({
"objectId": object_id,
"functionDeclaration": IS_FILE_INPUT_JS,
"returnByValue": true,
});
let check_response = effective
.send_command("Runtime.callFunctionOn", Some(check_params))
.await
.map_err(|e| AppError::interaction_failed("validate_file_input", &e.to_string()))?;
let is_file_input = check_response["result"]["value"].as_bool().unwrap_or(false);
if !is_file_input {
return Err(AppError::not_file_input(&args.target));
}
let set_files_params = serde_json::json!({
"files": resolved_paths,
"backendNodeId": backend_node_id,
});
effective
.send_command("DOM.setFileInputFiles", Some(set_files_params))
.await
.map_err(|e| AppError::interaction_failed("setFileInputFiles", &e.to_string()))?;
let change_params = serde_json::json!({
"objectId": object_id,
"functionDeclaration": DISPATCH_CHANGE_JS,
"arguments": [],
});
effective
.send_command("Runtime.callFunctionOn", Some(change_params))
.await
.map_err(|e| AppError::interaction_failed("dispatch_change", &e.to_string()))?;
let snapshot = if args.include_snapshot {
let url = get_current_url(&managed).await?;
Some(take_snapshot(&mut managed, &url, args.compact).await?)
} else {
None
};
let result = UploadResult {
uploaded: args.target.clone(),
files: resolved_paths,
size: total_size,
snapshot,
};
if global.output.plain {
print_upload_plain(&result);
Ok(())
} else {
output::emit_with_snapshot(
&result,
&global.output,
"form upload",
"snapshot",
crate::snapshot::summary_of_snapshot,
)
}
}
fn read_json_file(path: &Path) -> Result<String, AppError> {
std::fs::read_to_string(path).map_err(|e| AppError {
message: format!("File not found: {}: {e}", path.display()),
code: ExitCode::GeneralError,
custom_json: None,
})
}
#[allow(clippy::too_many_lines)]
async fn execute_submit(
global: &GlobalOpts,
args: &FormSubmitArgs,
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, Some(&args.target))
.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?;
}
managed.ensure_domain("Page").await?;
let effective = if let Some(ref ctx) = frame_ctx {
agentchrome::frame::frame_session(ctx, &managed)
} else {
&managed
};
let object_id = resolve_to_object_id(effective, &args.target).await?;
let find_form_params = serde_json::json!({
"objectId": object_id,
"functionDeclaration": FIND_FORM_JS,
"returnByValue": false,
});
let find_form_response = effective
.send_command("Runtime.callFunctionOn", Some(find_form_params))
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("NOT_IN_FORM") {
AppError::not_in_form(&args.target)
} else {
AppError::interaction_failed("find_form", &msg)
}
})?;
if let Some(exception) = find_form_response.get("exceptionDetails") {
let text = exception["exception"]["description"]
.as_str()
.unwrap_or("unknown");
if text.contains("NOT_IN_FORM") {
return Err(AppError::not_in_form(&args.target));
}
return Err(AppError::interaction_failed("find_form", text));
}
let form_object_id = find_form_response["result"]["objectId"]
.as_str()
.ok_or_else(|| AppError::not_in_form(&args.target))?;
let mut nav_rx = managed.subscribe("Page.frameNavigated").await?;
let pre_url = get_current_url(&managed).await?;
let submit_params = serde_json::json!({
"objectId": form_object_id,
"functionDeclaration": SUBMIT_JS,
"arguments": [],
});
effective
.send_command("Runtime.callFunctionOn", Some(submit_params))
.await
.map_err(|e| AppError::interaction_failed("submit", &e.to_string()))?;
tokio::time::sleep(Duration::from_millis(100)).await;
let navigated = nav_rx.try_recv().is_ok();
let url = if navigated {
let post_url = get_current_url(&managed).await?;
if post_url == pre_url {
None
} else {
Some(post_url)
}
} else {
None
};
let snapshot = if args.include_snapshot {
let current_url = if let Some(u) = &url {
u.clone()
} else {
pre_url
};
Some(take_snapshot(&mut managed, ¤t_url, args.compact).await?)
} else {
None
};
let result = SubmitResult {
submitted: args.target.clone(),
url,
snapshot,
};
if global.output.plain {
print_submit_plain(&result);
Ok(())
} else {
output::emit_with_snapshot(
&result,
&global.output,
"form submit",
"snapshot",
crate::snapshot::summary_of_snapshot,
)
}
}
pub async fn execute_form(global: &GlobalOpts, args: &FormArgs) -> Result<(), AppError> {
let frame = args.frame.as_deref();
match &args.command {
FormCommand::Fill(fill_args) => execute_fill(global, fill_args, frame).await,
FormCommand::FillMany(fill_many_args) => {
execute_fill_many(global, fill_many_args, frame).await
}
FormCommand::Clear(clear_args) => execute_clear(global, clear_args, frame).await,
FormCommand::Upload(upload_args) => execute_upload(global, upload_args, frame).await,
FormCommand::Submit(submit_args) => execute_submit(global, submit_args, frame).await,
}
}
pub async fn run_from_session(
_managed: &mut ManagedSession,
global: &GlobalOpts,
args: &FormArgs,
) -> Result<serde_json::Value, AppError> {
execute_form(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("1s"));
}
#[test]
fn is_css_selector_valid() {
assert!(snapshot::is_css_selector("css:#button"));
assert!(snapshot::is_css_selector("css:.class"));
assert!(snapshot::is_css_selector("css:div > p"));
}
#[test]
fn is_css_selector_invalid() {
assert!(!snapshot::is_css_selector("#button"));
assert!(!snapshot::is_css_selector("s1"));
assert!(!snapshot::is_css_selector("button"));
}
#[test]
fn fill_result_serialization() {
let result = FillResult {
filled: "s1".to_string(),
value: "John".to_string(),
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["filled"], "s1");
assert_eq!(json["value"], "John");
assert!(json.get("snapshot").is_none());
}
#[test]
fn fill_result_serialization_with_snapshot() {
let result = FillResult {
filled: "s1".to_string(),
value: "John".to_string(),
snapshot: Some(serde_json::json!({"role": "document"})),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["filled"], "s1");
assert_eq!(json["value"], "John");
assert!(json.get("snapshot").is_some());
}
#[test]
fn fill_result_css_selector_target() {
let result = FillResult {
filled: "css:#email".to_string(),
value: "user@example.com".to_string(),
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["filled"], "css:#email");
assert_eq!(json["value"], "user@example.com");
}
#[test]
fn fill_many_output_plain_serialization() {
let output = FillManyOutput::Plain(vec![
FillResult {
filled: "s1".to_string(),
value: "John".to_string(),
snapshot: None,
},
FillResult {
filled: "s2".to_string(),
value: "Doe".to_string(),
snapshot: None,
},
]);
let json: serde_json::Value = serde_json::to_value(&output).unwrap();
let arr = json.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["filled"], "s1");
assert_eq!(arr[0]["value"], "John");
assert_eq!(arr[1]["filled"], "s2");
assert_eq!(arr[1]["value"], "Doe");
}
#[test]
fn fill_many_output_with_snapshot_serialization() {
let output = FillManyOutput::WithSnapshot {
results: vec![FillResult {
filled: "s1".to_string(),
value: "John".to_string(),
snapshot: None,
}],
snapshot: serde_json::json!({"role": "document"}),
};
let json: serde_json::Value = serde_json::to_value(&output).unwrap();
assert!(json.get("results").is_some());
assert!(json.get("snapshot").is_some());
let results = json["results"].as_array().unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0]["filled"], "s1");
}
#[test]
fn clear_result_serialization() {
let result = ClearResult {
cleared: "s1".to_string(),
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["cleared"], "s1");
assert!(json.get("snapshot").is_none());
}
#[test]
fn clear_result_serialization_with_snapshot() {
let result = ClearResult {
cleared: "s1".to_string(),
snapshot: Some(serde_json::json!({"role": "document"})),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["cleared"], "s1");
assert!(json.get("snapshot").is_some());
}
#[test]
fn fill_entry_deserialization_with_target() {
let json = r#"[{"target":"s1","value":"John"},{"target":"s2","value":"Doe"}]"#;
let entries: Vec<FillEntry> = serde_json::from_str(json).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].target, "s1");
assert_eq!(entries[0].value, "John");
assert_eq!(entries[1].target, "s2");
assert_eq!(entries[1].value, "Doe");
}
#[test]
fn fill_entry_deserialization_with_uid_alias() {
let json = r#"[{"uid":"s1","value":"John"},{"uid":"s2","value":"Doe"}]"#;
let entries: Vec<FillEntry> = serde_json::from_str(json).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].target, "s1");
assert_eq!(entries[0].value, "John");
assert_eq!(entries[1].target, "s2");
assert_eq!(entries[1].value, "Doe");
}
#[test]
fn fill_entry_invalid_json() {
let json = r#"[{"target":"s1"}]"#; let result: Result<Vec<FillEntry>, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn fill_entry_not_array() {
let json = r#"{"target":"s1","value":"John"}"#;
let result: Result<Vec<FillEntry>, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn fill_plain_output_format() {
let result = FillResult {
filled: "s1".to_string(),
value: "test".to_string(),
snapshot: None,
};
print_fill_plain(&result);
}
#[test]
fn clear_plain_output_format() {
let result = ClearResult {
cleared: "s1".to_string(),
snapshot: None,
};
print_clear_plain(&result);
}
#[test]
fn upload_result_serialization() {
let result = UploadResult {
uploaded: "s5".to_string(),
files: vec!["/tmp/photo.jpg".to_string()],
size: 24576,
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["uploaded"], "s5");
assert_eq!(json["files"].as_array().unwrap().len(), 1);
assert_eq!(json["files"][0], "/tmp/photo.jpg");
assert_eq!(json["size"], 24576);
assert!(json.get("snapshot").is_none());
}
#[test]
fn upload_result_serialization_with_snapshot() {
let result = UploadResult {
uploaded: "s5".to_string(),
files: vec!["/tmp/photo.jpg".to_string()],
size: 24576,
snapshot: Some(serde_json::json!({"role": "document"})),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["uploaded"], "s5");
assert_eq!(json["size"], 24576);
assert!(json.get("snapshot").is_some());
}
#[test]
fn upload_result_multiple_files() {
let result = UploadResult {
uploaded: "s3".to_string(),
files: vec!["/tmp/doc1.pdf".to_string(), "/tmp/doc2.pdf".to_string()],
size: 102_400,
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["uploaded"], "s3");
assert_eq!(json["files"].as_array().unwrap().len(), 2);
assert_eq!(json["files"][0], "/tmp/doc1.pdf");
assert_eq!(json["files"][1], "/tmp/doc2.pdf");
assert_eq!(json["size"], 102_400);
}
#[test]
fn upload_result_css_selector_target() {
let result = UploadResult {
uploaded: "css:#file-upload".to_string(),
files: vec!["/tmp/document.pdf".to_string()],
size: 51200,
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["uploaded"], "css:#file-upload");
}
#[test]
fn upload_plain_output_format() {
let result = UploadResult {
uploaded: "s5".to_string(),
files: vec!["/tmp/photo.jpg".to_string()],
size: 24576,
snapshot: None,
};
print_upload_plain(&result);
}
#[test]
fn submit_result_serialization_no_url() {
let result = SubmitResult {
submitted: "s3".to_string(),
url: None,
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["submitted"], "s3");
assert!(json.get("url").is_none());
assert!(json.get("snapshot").is_none());
}
#[test]
fn submit_result_serialization_with_url() {
let result = SubmitResult {
submitted: "s3".to_string(),
url: Some("https://example.com/dashboard".to_string()),
snapshot: None,
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["submitted"], "s3");
assert_eq!(json["url"], "https://example.com/dashboard");
assert!(json.get("snapshot").is_none());
}
#[test]
fn submit_result_serialization_with_snapshot() {
let result = SubmitResult {
submitted: "s3".to_string(),
url: None,
snapshot: Some(serde_json::json!({"role": "document"})),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["submitted"], "s3");
assert!(json.get("url").is_none());
assert!(json.get("snapshot").is_some());
}
#[test]
fn submit_result_serialization_with_url_and_snapshot() {
let result = SubmitResult {
submitted: "css:#login-form".to_string(),
url: Some("https://example.com/home".to_string()),
snapshot: Some(serde_json::json!({"role": "document"})),
};
let json: serde_json::Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["submitted"], "css:#login-form");
assert_eq!(json["url"], "https://example.com/home");
assert!(json.get("snapshot").is_some());
}
#[test]
fn submit_plain_output_format() {
let result = SubmitResult {
submitted: "s3".to_string(),
url: None,
snapshot: None,
};
print_submit_plain(&result);
}
#[test]
fn submit_plain_output_format_with_url() {
let result = SubmitResult {
submitted: "s3".to_string(),
url: Some("https://example.com".to_string()),
snapshot: None,
};
print_submit_plain(&result);
}
#[test]
fn is_text_input_textarea() {
assert!(is_text_input("textarea", None));
}
#[test]
fn is_text_input_default_input() {
assert!(is_text_input("input", None));
}
#[test]
fn is_text_input_text_types() {
for t in &[
"text", "password", "email", "number", "tel", "url", "search",
] {
assert!(
is_text_input("input", Some(t)),
"expected true for type={t}"
);
}
}
#[test]
fn is_text_input_non_text_types() {
for t in &[
"checkbox", "radio", "file", "hidden", "submit", "button", "reset", "image", "range",
"color", "date",
] {
assert!(
!is_text_input("input", Some(t)),
"expected false for type={t}"
);
}
}
#[test]
fn is_text_input_select() {
assert!(!is_text_input("select", None));
}
#[test]
fn is_text_input_div() {
assert!(!is_text_input("div", None));
}
#[test]
fn is_fillable_via_js_select() {
assert!(is_fillable_via_js("select", None));
}
#[test]
fn is_fillable_via_js_checkbox_and_radio() {
assert!(is_fillable_via_js("input", Some("checkbox")));
assert!(is_fillable_via_js("input", Some("radio")));
}
#[test]
fn is_fillable_via_js_rejects_non_fillable_tags() {
for tag in &["div", "canvas", "button", "a", "span", "textarea"] {
assert!(
!is_fillable_via_js(tag, None),
"expected false for tag={tag}"
);
}
}
#[test]
fn is_fillable_via_js_rejects_text_inputs() {
assert!(!is_fillable_via_js("input", None));
assert!(!is_fillable_via_js("input", Some("text")));
}
}