fix(fill/tabs): dispatch real input/change/blur (#25); close <tab> wording + chrome-use current (#26)

#25 — fill() didn't fire the events framework inputs / site autocomplete need:
it set value directly (bypassing React's value-tracker) and typed via
Input.insertText, so controlled components and input/change/blur listeners (e.g.
Mercari's postal-code → 都道府県 lookup) never ran though the value showed. fill
now emulates a real edit: focus, set through the element's prototype value setter
(React _valueTracker registers), then dispatch input → input → change → blur/
focusout. SELECT and contenteditable handled too. type <sel> <text> remains for
per-keystroke sites. Verified live: an input wired with input/change/blur fired
'IICB' from one fill.

#26 (ergonomics):
- 'close <tab>' now closes just that tab and prints 'Tab [tN] closed'; bare
  'close' still closes the browser. Previously 'close t12' ran a browser close
  and alarmingly printed 'Browser closed'.
- new 'chrome-use current': prints the active tab's stable handle (tabId + CDP
  targetId + url/title), refreshed live — so an agent holds the targetId (which
  survives cross-process nav) instead of re-deriving 'which tab is live' from
  'tabs' every step. The deeper tab-id churn is the #21/#23 stable-targetId story.

Tests cover fill events (live), close tab-vs-browser parse, and current.
This commit is contained in:
leeguooooo
2026-06-15 11:26:34 +09:00
parent 9ab8753b48
commit 33269adc1a
5 changed files with 121 additions and 33 deletions
+38 -1
View File
@@ -1026,7 +1026,22 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
} }
// === Close === // === Close ===
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })), "close" | "quit" | "exit" => {
// `close <tab>` closes only that tab (and the output says "Tab
// closed"); bare `close` closes the browser/session. `close --all` is
// intercepted earlier in the dispatcher. Previously `close t12` still
// ran a browser close and alarmingly printed "Browser closed" (#26).
if let Some(tab_ref) = rest.iter().find(|a| !a.starts_with("--")) {
Ok(json!({ "id": id, "action": "tab_close", "tabId": tab_ref }))
} else {
Ok(json!({ "id": id, "action": "close" }))
}
}
// The active tab's stable handle — `targetId` survives cross-process
// navigation and is reusable across sessions, so an agent can hold it
// instead of re-deriving "which tab is live" from `tabs` each step (#26).
"current" => Ok(json!({ "id": id, "action": "current" })),
// === Inspect === // === Inspect ===
"inspect" => Ok(json!({ "id": id, "action": "inspect" })), "inspect" => Ok(json!({ "id": id, "action": "inspect" })),
@@ -4014,6 +4029,28 @@ mod tests {
assert_eq!(cmd["tabId"], "docs"); assert_eq!(cmd["tabId"], "docs");
} }
#[test]
fn test_close_tab_vs_browser() {
// `close <tab>` closes that tab (says "Tab closed"); bare `close` closes
// the browser (#26).
let tab = parse_command(&args("close t12"), &default_flags()).unwrap();
assert_eq!(tab["action"], "tab_close");
assert_eq!(tab["tabId"], "t12");
let browser = parse_command(&args("close"), &default_flags()).unwrap();
assert_eq!(browser["action"], "close");
// `quit`/`exit` aliases still browser-close.
assert_eq!(
parse_command(&args("quit"), &default_flags()).unwrap()["action"],
"close"
);
}
#[test]
fn test_current_command() {
let cmd = parse_command(&args("current"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "current");
}
#[test] #[test]
fn test_tab_sends_string_tab_id() { fn test_tab_sends_string_tab_id() {
let cmd = parse_command(&args("tab t2"), &default_flags()).unwrap(); let cmd = parse_command(&args("tab t2"), &default_flags()).unwrap();
+13
View File
@@ -1395,6 +1395,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
"count" => handle_count(cmd, state).await, "count" => handle_count(cmd, state).await,
"styles" => handle_styles(cmd, state).await, "styles" => handle_styles(cmd, state).await,
"bringtofront" => handle_bringtofront(state).await, "bringtofront" => handle_bringtofront(state).await,
"current" => handle_current(state).await,
"timezone" => handle_timezone(cmd, state).await, "timezone" => handle_timezone(cmd, state).await,
"locale" => handle_locale(cmd, state).await, "locale" => handle_locale(cmd, state).await,
"geolocation" => handle_geolocation(cmd, state).await, "geolocation" => handle_geolocation(cmd, state).await,
@@ -5333,6 +5334,18 @@ async fn handle_bringtofront(state: &DaemonState) -> Result<Value, String> {
Ok(json!({ "broughtToFront": true })) Ok(json!({ "broughtToFront": true }))
} }
async fn handle_current(state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
// Refresh so `current` reflects the live URL/title even after a cross-process
// nav (the relay's cached target_info can lag) (#26).
mgr.resync_targets().await.ok();
let mut info = mgr.active_page_info().ok_or("No active tab")?;
if let Some(obj) = info.as_object_mut() {
obj.insert("current".to_string(), json!(true));
}
Ok(info)
}
async fn handle_timezone(cmd: &Value, state: &DaemonState) -> Result<Value, String> { async fn handle_timezone(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let timezone = cmd let timezone = cmd
+16
View File
@@ -1263,6 +1263,22 @@ impl BrowserManager {
.collect() .collect()
} }
/// The active tab's stable handle + current location, for `chrome-use
/// current` (#26). `targetId` survives cross-process navigation, so it's the
/// handle an agent should hold across a multi-step flow.
pub fn active_page_info(&self) -> Option<Value> {
let i = self.resolved_active_index();
self.pages.get(i).map(|p| {
json!({
"tabId": format_tab_id(p.tab_id),
"targetId": p.target_id,
"label": p.label,
"url": p.url,
"title": p.title,
})
})
}
/// Stable `tab_id` for a page identified by its CDP `targetId`, if tracked. /// Stable `tab_id` for a page identified by its CDP `targetId`, if tracked.
/// Lets callers adopt a tab by the cross-session-stable target id. /// Lets callers adopt a tab by the cross-session-stable target id.
pub fn tab_id_for_target(&self, target_id: &str) -> Option<u32> { pub fn tab_id_for_target(&self, target_id: &str) -> Option<u32> {
+39 -32
View File
@@ -306,32 +306,50 @@ pub async fn fill(
) )
.await?; .await?;
// Focus the element // Emulate a real edit so framework-controlled inputs (React/Vue) and
client // site-side listeners actually see the change (issue #25): the old path set
.send_command_typed::<_, Value>( // `this.value` directly and used Input.insertText, which left React's
"Runtime.callFunctionOn", // internal value-tracker out of sync and never fired change/blur — so
&CallFunctionOnParams { // dependent logic (e.g. Mercari's postal-code → 都道府県 autocomplete) never
function_declaration: "function() { this.focus(); }".to_string(), // ran even though the value was visible. Set the value through the element's
object_id: Some(object_id.clone()), // PROTOTYPE setter (which React's _valueTracker hooks), then dispatch
arguments: None, // input → change → blur/focusout. `type <sel> <text>` remains for sites that
return_by_value: Some(true), // need per-keystroke events.
await_promise: Some(false), let fill_js = format!(
}, r#"function() {{
Some(&effective_session_id), const el = this;
) const v = {val};
.await?; try {{ el.focus(); }} catch (e) {{}}
const tag = el.tagName;
const fire = (type, ctor) => el.dispatchEvent(new (ctor || Event)(type, {{ bubbles: true }}));
if (tag === 'SELECT') {{
el.value = v; fire('input'); fire('change'); return true;
}}
if (el.isContentEditable) {{
el.textContent = v; fire('input', window.InputEvent || Event); fire('change');
try {{ el.blur(); }} catch (e) {{}} fire('focusout'); return true;
}}
const proto = tag === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
const set = desc && desc.set ? (x) => desc.set.call(el, x) : (x) => {{ el.value = x; }};
set(''); // reset the framework tracker
fire('input', window.InputEvent || Event);
set(v); // native setter → React/Vue registers
fire('input', window.InputEvent || Event);
fire('change');
try {{ el.blur(); }} catch (e) {{}}
fire('focusout'); // blur-triggered lookups/validation
return true;
}}"#,
val = serde_json::to_string(value).unwrap_or_default()
);
// Select all + delete to clear
client client
.send_command_typed::<_, Value>( .send_command_typed::<_, Value>(
"Runtime.callFunctionOn", "Runtime.callFunctionOn",
&CallFunctionOnParams { &CallFunctionOnParams {
function_declaration: r#"function() { function_declaration: fill_js,
this.select && this.select();
this.value = '';
this.dispatchEvent(new Event('input', { bubbles: true }));
}"#
.to_string(),
object_id: Some(object_id), object_id: Some(object_id),
arguments: None, arguments: None,
return_by_value: Some(true), return_by_value: Some(true),
@@ -341,17 +359,6 @@ pub async fn fill(
) )
.await?; .await?;
// Insert text (keyboard input dispatched at page level, use parent session_id)
client
.send_command_typed::<_, Value>(
"Input.insertText",
&InsertTextParams {
text: value.to_string(),
},
Some(session_id),
)
.await?;
Ok(()) Ok(())
} }
+15
View File
@@ -209,6 +209,21 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
); );
} }
// `current`: the active tab's stable handle (#26).
if data
.get("current")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
let tid = data.get("tabId").and_then(|v| v.as_str()).unwrap_or("?");
let title = data.get("title").and_then(|v| v.as_str()).unwrap_or("");
let url = data.get("url").and_then(|v| v.as_str()).unwrap_or("");
let target = data.get("targetId").and_then(|v| v.as_str()).unwrap_or("");
println!("{} [{}] {} - {}", color::cyan(""), tid, title, url);
println!(" {}", color::dim(&format!("target: {}", target)));
return;
}
// Dialog status response // Dialog status response
if action == Some("dialog") { if action == Some("dialog") {
if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) { if let Some(has_dialog) = data.get("hasDialog").and_then(|v| v.as_bool()) {