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:
+38
-1
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user