diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 981749e9..02652fca 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1745,8 +1745,9 @@ export type BrowserWatchParams = { botId: BotId, }; export type BrowserResizeParams = { botId: BotId, width: number, height: number, /** * The screen's pixels per pixel of the app, in percent (its - * `devicePixelRatio` times 100): the page is drawn that sharp for the - * owner, up to twice (spec 21.3). Absent, 100. + * `devicePixelRatio` times 100). The daemon ignores it: the page is + * always drawn in its own pixels, because a sharper page made clicks + * land at the wrong point (spec 21.3, 19). Absent, 100. */ scale?: number, }; diff --git a/app/src/motion.css b/app/src/motion.css index dfa5a54d..8a3691c3 100644 --- a/app/src/motion.css +++ b/app/src/motion.css @@ -174,8 +174,8 @@ pointer-events: none; translate: -4px -3px; transition: - left 520ms var(--ease-out), - top 520ms var(--ease-out); + left 260ms var(--ease-out), + top 260ms var(--ease-out); } .bot-cursor > span { @@ -190,7 +190,7 @@ border: 3px solid; border-radius: 9999px; pointer-events: none; - animation: ripple 700ms var(--ease-out) 520ms both; + animation: ripple 700ms var(--ease-out) 260ms both; } .bot-typing { diff --git a/crates/botloft-core/src/protocol/browser.rs b/crates/botloft-core/src/protocol/browser.rs index 7b51fbd7..532361ab 100644 --- a/crates/botloft-core/src/protocol/browser.rs +++ b/crates/botloft-core/src/protocol/browser.rs @@ -167,8 +167,9 @@ pub struct BrowserResizeParams { pub width: u32, pub height: u32, /// The screen's pixels per pixel of the app, in percent (its - /// `devicePixelRatio` times 100): the page is drawn that sharp for the - /// owner, up to twice (spec 21.3). Absent, 100. + /// `devicePixelRatio` times 100). The daemon ignores it: the page is + /// always drawn in its own pixels, because a sharper page made clicks + /// land at the wrong point (spec 21.3, 19). Absent, 100. #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(test, ts(optional))] pub scale: Option, diff --git a/crates/botloftd/src/browser/actions.js b/crates/botloftd/src/browser/actions.js index e391b863..6a0f856f 100644 --- a/crates/botloftd/src/browser/actions.js +++ b/crates/botloftd/src/browser/actions.js @@ -19,30 +19,73 @@ return { left, top, width: rect.width, height: rect.height }; }; - // Where to click an element, after scrolling it into the middle of the - // screen, and what else is on top of that point, if anything. + // Whether something else is on top of a point of an element: true when + // the point shows the element, a part of it or what it sits in. + const shows = (el, x, y) => { + const hit = el.ownerDocument.elementFromPoint(x, y); + return !!hit && (hit === el || el.contains(hit) || hit.contains(el)); + }; + + // A point inside the element that is not covered, the middle first, then + // the corners and edges of its middle part, or null. Returns the fraction + // of the box it is at. + const freePoint = (el, local) => { + const view = el.ownerDocument.defaultView; + for (const fy of [0.5, 0.2, 0.8]) { + for (const fx of [0.5, 0.2, 0.8]) { + const x = local.left + local.width * fx; + const y = local.top + local.height * fy; + if (x < 0 || y < 0 || x >= view.innerWidth || y >= view.innerHeight) continue; + if (shows(el, x, y)) return { fx, fy }; + } + } + return null; + }; + + // Where to click an element. It scrolls into the middle of the screen; + // when a fixed header, a banner or a chat widget covers it there, it tries + // the other alignments and the other points of the element, and when + // nothing is free it says so (`script`), so the click can go through the + // page instead of the mouse. Also what is still on top, if anything. const point = (ref) => { const el = get(ref); if (!el) return { missing: true, next: next() }; - el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); + let pick = null; + for (const block of ["center", "end", "start", "nearest"]) { + el.scrollIntoView({ block, inline: "center", behavior: "instant" }); + const local = el.getBoundingClientRect(); + if (local.width === 0 || local.height === 0) break; + pick = freePoint(el, local); + if (pick) break; + } + if (!pick) el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); const box = topRect(el); const role = roleOf(el, styleOf(el), "auto") ?? el.localName; const label = clean(nameOf(el), 60); if (box.width === 0 || box.height === 0) return { hidden: true, role, label, next: next() }; - const x = Math.min(Math.max(box.left + box.width / 2, 0), innerWidth - 1); - const y = Math.min(Math.max(box.top + box.height / 2, 0), innerHeight - 1); + const [fx, fy] = pick ? [pick.fx, pick.fy] : [0.5, 0.5]; + const x = Math.min(Math.max(box.left + box.width * fx, 0), innerWidth - 1); + const y = Math.min(Math.max(box.top + box.height * fy, 0), innerHeight - 1); const local = el.getBoundingClientRect(); const hit = el.ownerDocument.elementFromPoint( - local.left + local.width / 2, - local.top + local.height / 2, + local.left + local.width * fx, + local.top + local.height * fy, ); let cover = null; - if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) { + if (!pick && hit && hit !== el && !el.contains(hit) && !hit.contains(el)) { let shown = hit; while (shown && !roleOf(shown, styleOf(shown), "auto")) shown = shown.parentElement; cover = shown ? token(shown, roleOf(shown, styleOf(shown), "auto")) : `<${hit.localName}>`; } - return { x, y, role, label, text: isTextField(el), cover, next: next() }; + return { x, y, role, label, text: isTextField(el), cover, script: !pick && cover !== null, next: next() }; + }; + + // The page's own click on an element, for when the mouse cannot reach it. + const clickScript = (ref) => { + const el = get(ref); + if (!el) return { missing: true }; + el.click(); + return { ok: true }; }; // Focuses a text field and selects what it holds, so typing replaces it. @@ -112,5 +155,5 @@ return el && el !== document.body && isTextField(el) ? meaning(el) : { none: true }; }; - return { point, prepareType, select, scrollEnd, at, focused }; + return { point, clickScript, prepareType, select, scrollEnd, at, focused }; } diff --git a/crates/botloftd/src/browser/events.rs b/crates/botloftd/src/browser/events.rs index 10bfeb4b..68225a91 100644 --- a/crates/botloftd/src/browser/events.rs +++ b/crates/botloftd/src/browser/events.rs @@ -211,14 +211,7 @@ impl Session { size("deviceWidth", viewport.width), size("deviceHeight", viewport.height), ); - // The screencast draws in the page's own pixels, however sharp the - // page is (spec 19): on a sharper screen its frame only says the page - // changed, and the frame sent is a screenshot, which is that sharp. - let sharp = active && viewport.scale > 100; - if active - && !sharp - && let Some(data) = params["data"].as_str() - { + if active && let Some(data) = params["data"].as_str() { hooks.frames.send_replace(Some(Arc::new(BrowserFrame { bot_id: hooks.bot.clone(), data: data.to_owned(), @@ -229,26 +222,7 @@ impl Session { let cdp = self.cdp.clone(); let session = session.to_owned(); let ack = json!({ "sessionId": params["sessionId"] }); - let (frames, bot) = (hooks.frames.clone(), hooks.bot.clone()); tokio::spawn(async move { - if sharp { - let shot = json!({ "format": "jpeg", "quality": 60, "optimizeForSpeed": true }); - let taken = cdp - .call(Some(&session), "Page.captureScreenshot", shot) - .await; - if let Some(data) = taken - .ok() - .and_then(|shot| shot["data"].as_str().map(str::to_owned)) - { - frames.send_replace(Some(Arc::new(BrowserFrame { - bot_id: bot, - data, - width, - height, - }))); - } - } - // Confirmed only after, so screenshots never pile up. tokio::time::sleep(gap).await; cdp.send(Some(&session), "Page.screencastFrameAck", ack); }); diff --git a/crates/botloftd/src/browser/page.rs b/crates/botloftd/src/browser/page.rs index 850a3c88..cd7ec02e 100644 --- a/crates/botloftd/src/browser/page.rs +++ b/crates/botloftd/src/browser/page.rs @@ -19,6 +19,9 @@ pub struct Done { pub label: Option, /// Another element was on top of the one clicked. pub cover: Option, + /// Nothing of the element was free of what covered it, so the click went + /// through the page's own script and not the mouse. + pub scripted: bool, /// What `browser_select` chose. pub chosen: Option, } @@ -28,6 +31,8 @@ pub struct Done { pub struct Aim { reference: String, point: Value, + /// Covered whatever the point: only the page's own script can click it. + script: bool, pub done: Done, } @@ -81,10 +86,12 @@ impl Session { point: point["x"].as_f64().zip(point["y"].as_f64()), label, cover: point["cover"].as_str().map(str::to_owned), + scripted: false, chosen: None, }; Ok(Aim { reference: reference.to_owned(), + script: point["script"] == true, point, done, }) @@ -114,8 +121,27 @@ impl Session { Ok(()) } - pub async fn click(&self, Aim { done, .. }: Aim) -> Result { + pub async fn click( + &self, + Aim { + reference, + script, + mut done, + .. + }: Aim, + ) -> Result { let marks = self.marks(); + if script { + let result = self + .reader(&format!("clickScript({})", json!(reference))) + .await?; + if result["missing"] == true { + return Err(BrowserError::Stale(reference)); + } + done.scripted = true; + self.settle(marks).await; + return Ok(done); + } self.mouse_click(done.point.unwrap_or_default()).await?; self.settle(marks).await; Ok(done) @@ -129,10 +155,16 @@ impl Session { submit: bool, ) -> Result { let Aim { - reference, done, .. + reference, + script, + done, + .. } = aim; let marks = self.marks(); - self.mouse_click(done.point.unwrap_or_default()).await?; + // A field nothing frees is still focused by `prepareType` below. + if !script { + self.mouse_click(done.point.unwrap_or_default()).await?; + } let ready = self .reader(&format!("prepareType({})", json!(reference))) .await?; diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs index 379779d7..7d6032e8 100644 --- a/crates/botloftd/src/browser/session.rs +++ b/crates/botloftd/src/browser/session.rs @@ -27,8 +27,6 @@ pub(super) const FRAME_GAP: Duration = Duration::from_millis(66); /// ...and ~30 while the browser is in the owner's hands, so the page keeps /// up with them as they scroll and point (spec 21.8). pub(super) const QUICK_GAP: Duration = Duration::from_millis(33); -/// The largest side of a frame that only says the page changed. -const SIGNAL: u32 = 64; /// How long the first tab may take to show up. const FIRST_TAB_TIMEOUT: Duration = Duration::from_secs(5); @@ -259,16 +257,9 @@ impl Session { pub(super) async fn cast(&self, session: &str, on: bool) { let result = if on { - // Frames come the size of the page, however tall it is. On a - // sharper screen they only say the page changed (`events.rs`), - // so they come small. - let (max_width, max_height) = if self.viewport().scale > 100 { - (SIGNAL, SIGNAL) - } else { - (MAX_WIDTH, MAX_HEIGHT) - }; + // Frames come the size of the page, however tall it is. let params = json!({ - "format": "jpeg", "quality": 60, "maxWidth": max_width, "maxHeight": max_height, + "format": "jpeg", "quality": 60, "maxWidth": MAX_WIDTH, "maxHeight": MAX_HEIGHT, }); self.cdp .call(Some(session), "Page.startScreencast", params) diff --git a/crates/botloftd/src/browser/settle.rs b/crates/botloftd/src/browser/settle.rs index dbb83ceb..7a00bfee 100644 --- a/crates/botloftd/src/browser/settle.rs +++ b/crates/botloftd/src/browser/settle.rs @@ -8,11 +8,18 @@ use super::session::Session; const LOAD_WAIT: Duration = Duration::from_secs(15); const QUIET: Duration = Duration::from_millis(500); const QUIET_WAIT: Duration = Duration::from_secs(3); +/// The same wait on a page that was never quiet before the action (an +/// analytics script, a chat widget, a live feed): its network will not go +/// quiet because of the click, so waiting for it only costs the bot 3 s per +/// action (measured: 3.2 s a click, against 0.17 s on a quiet page). +const NOISY_WAIT: Duration = Duration::from_millis(600); /// The active tab before an action, to tell whether it navigated. pub(super) struct Marks { target: String, navigations: u64, + /// The network was busy just before the action, whatever the action does. + noisy: bool, } impl Session { @@ -22,6 +29,8 @@ impl Session { Marks { target: tab.map(|tab| tab.target.clone()).unwrap_or_default(), navigations: tab.map_or(0, |tab| tab.navigations), + noisy: tab + .is_some_and(|tab| !tab.inflight.is_empty() || tab.network_at.elapsed() < QUIET), } } @@ -46,7 +55,7 @@ impl Session { } let _ = tokio::time::timeout(left, notified).await; } - let deadline = Instant::now() + QUIET_WAIT; + let deadline = Instant::now() + if before.noisy { NOISY_WAIT } else { QUIET_WAIT }; loop { let notified = self.changed.notified(); let quiet_for = { diff --git a/crates/botloftd/src/browser/viewport.rs b/crates/botloftd/src/browser/viewport.rs index 493a2476..c37d4e57 100644 --- a/crates/botloftd/src/browser/viewport.rs +++ b/crates/botloftd/src/browser/viewport.rs @@ -20,9 +20,13 @@ pub const MAX_WIDTH: u32 = 1600; /// How short and how tall the owner's panel may make the page. pub const MIN_HEIGHT: u32 = 600; pub const MAX_HEIGHT: u32 = 2000; -/// The sharpest the page is drawn for the owner, in percent: two pixels of -/// their screen to one of the page. -pub const MAX_SCALE: u32 = 200; +/// The sharpest the page is drawn for the owner, in percent: always one pixel +/// of the page to one of the screenshot. A sharper page (`deviceScaleFactor` +/// above 1, with a picture taken for each frame) made the browser read where +/// the bot's and the owner's clicks land wrongly, dividing them by the +/// factor, on any screen scaled above 100% (measured with the Edge 154, +/// spec 19). The panel scales the frame up instead. +pub const MAX_SCALE: u32 = 100; /// The size of the page, the same in every tab. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -59,7 +63,8 @@ impl Viewport { } } - /// The same page, drawn `scale` percent as sharp, within limits. + /// The same page, drawn `scale` percent as sharp, within limits: today + /// the limit is the page's own pixels (`MAX_SCALE`). pub fn at_scale(self, scale: u32) -> Self { Self { scale: scale.clamp(100, MAX_SCALE), @@ -132,31 +137,17 @@ impl Session { /// The screen as a JPEG, base64, for the bot: the size of the page, /// however sharp the owner's screen has it drawn. pub async fn screenshot(&self) -> Result { - self.capture(false).await + self.capture().await } - /// The screen as a JPEG, base64, as sharp as the owner's panel draws it. + /// The screen as a JPEG, base64, for whoever looks while the browser rests. pub async fn picture(&self) -> Result { - self.capture(true).await + self.capture().await } - async fn capture(&self, sharp: bool) -> Result { + async fn capture(&self) -> Result { let (session, _) = self.page()?; - let page = self.viewport(); - let mut params = json!({ "format": "jpeg", "quality": 70 }); - if !sharp && page.scale > 100 { - // The page where it is scrolled to, scaled back down (spec 19). - let metrics = self - .cdp - .call(Some(&session), "Page.getLayoutMetrics", json!({})) - .await?; - let view = &metrics["cssVisualViewport"]; - params["clip"] = json!({ - "x": view["pageX"], "y": view["pageY"], - "width": page.width, "height": page.height, - "scale": 1.0 / page.factor(), - }); - } + let params = json!({ "format": "jpeg", "quality": 70 }); let shot = self .cdp .call(Some(&session), "Page.captureScreenshot", params) @@ -203,15 +194,16 @@ mod tests { } #[test] - fn the_page_is_drawn_as_sharp_as_the_screen_up_to_twice() { + fn the_page_is_always_drawn_in_its_own_pixels() { let page = Viewport::fitting(1000, 700); assert_eq!(page.scale, 100); - assert_eq!(page.at_scale(150).scale, 150); - assert_eq!(page.at_scale(300).scale, MAX_SCALE); - // A screen less sharp than the page still gets one pixel each. - assert_eq!(page.at_scale(80).scale, 100); - assert_eq!(page.at_scale(150).metrics()["deviceScaleFactor"], 1.5); - assert_eq!(page.at_scale(150).width, 1000); + // A sharper screen asks for more, and the page stays at one pixel + // each: a scale above 1 made clicks land at the wrong point. + for asked in [80, 125, 150, 200, 300] { + assert_eq!(page.at_scale(asked).scale, 100); + assert_eq!(page.at_scale(asked).metrics()["deviceScaleFactor"], 1.0); + assert_eq!(page.at_scale(asked).width, 1000); + } } #[test] diff --git a/crates/botloftd/src/tools/browser.rs b/crates/botloftd/src/tools/browser.rs index ff7d01c4..a920c704 100644 --- a/crates/botloftd/src/tools/browser.rs +++ b/crates/botloftd/src/tools/browser.rs @@ -15,7 +15,7 @@ use super::browser_help::ask_owner; use super::browser_reply::{answer, describe, point, report, unavailable}; use super::browser_sites::{allowed, page_allowed}; use super::calls::explain; -use crate::browser::{Done, OWNER_WAIT, Scroll, sites}; +use crate::browser::{Aim, Done, OWNER_WAIT, Scroll, Session, sites}; use crate::service::bots; use crate::state::Daemon; @@ -172,11 +172,7 @@ async fn run( // The cursor goes first; these report before they act. Tool::Click { reference } => { let kind = BrowserActionKind::Click; - let aim = session - .aim(&reference) - .await - .map_err(|err| describe(&err))?; - point(daemon, id, kind, &aim).await; + let aim = aim_at(daemon, id, &session, kind, &reference, false).await?; (kind, session.click(aim).await) } Tool::Type { @@ -185,20 +181,12 @@ async fn run( submit, } => { let kind = BrowserActionKind::Type; - let aim = session - .aim_field(&reference) - .await - .map_err(|err| describe(&err))?; - point(daemon, id, kind, &aim).await; + let aim = aim_at(daemon, id, &session, kind, &reference, true).await?; (kind, session.type_text(aim, &text, submit).await) } Tool::Select { reference, option } => { let kind = BrowserActionKind::Select; - let aim = session - .aim(&reference) - .await - .map_err(|err| describe(&err))?; - point(daemon, id, kind, &aim).await; + let aim = aim_at(daemon, id, &session, kind, &reference, false).await?; (kind, session.choose(aim, &option).await) } Tool::Press { key } => { @@ -235,9 +223,16 @@ async fn run( } let mut notes = Vec::new(); if let Some(cover) = &done.cover { - notes.push(format!( - "The click landed on {cover}, which was on top of the element." - )); + if done.scripted { + notes.push(format!( + "{cover} covers the whole element, so the click was made through the page's \ + own script instead of the mouse; check that it did what you wanted." + )); + } else { + notes.push(format!( + "The click landed on {cover}, which was on top of the element." + )); + } } if let Some(chosen) = &done.chosen { notes.push(format!("Chose \"{chosen}\".")); @@ -245,6 +240,40 @@ async fn run( answer(&session, &bot, 0, Some(notes)).await } +/// Finds the element and shows the owner where the bot is about to act. While +/// someone watches, the tool waits for the cursor to get there, and the page +/// can change meanwhile (an image loads, a banner opens): the element is found +/// again after the wait, so the action goes where the element is now. +async fn aim_at( + daemon: &Daemon, + id: &BotId, + session: &Session, + kind: BrowserActionKind, + reference: &str, + field: bool, +) -> Result { + let find = || async { + if field { + session.aim_field(reference).await + } else { + session.aim(reference).await + } + }; + let aim = find().await.map_err(|err| describe(&err))?; + if !point(daemon, id, kind, &aim).await { + return Ok(aim); + } + let fresh = find().await.map_err(|err| describe(&err))?; + let apart = |a: Option<(f64, f64)>, b: Option<(f64, f64)>| match (a, b) { + (Some((ax, ay)), Some((bx, by))) => (ax - bx).abs() > 3.0 || (ay - by).abs() > 3.0, + (a, b) => a != b, + }; + if apart(aim.done.point, fresh.done.point) { + report(daemon, id, kind, &fresh.done); + } + Ok(fresh) +} + /// The note for the bot when the owner left it on another tab. fn switched(moved: bool) -> Option> { moved.then(|| vec![SWITCHED.to_owned()]) diff --git a/crates/botloftd/src/tools/browser_reply.rs b/crates/botloftd/src/tools/browser_reply.rs index d2fcf6b8..de600b92 100644 --- a/crates/botloftd/src/tools/browser_reply.rs +++ b/crates/botloftd/src/tools/browser_reply.rs @@ -57,17 +57,26 @@ pub(super) async fn answer( } /// How long the owner's cursor takes to glide to a point (`.bot-cursor` in -/// the app's `motion.css`, 520 ms), with a little to spare. -const GLIDE: Duration = Duration::from_millis(560); +/// the app's `motion.css`, 260 ms), with a little to spare. It was 520 ms, +/// which made every click, typing and choice take half a second more while +/// the owner watched (spec 21.7). +const GLIDE: Duration = Duration::from_millis(300); /// Shows the owner where the bot is about to act and, while someone /// watches, waits for the cursor to get there, so the page changes after -/// the cursor arrives and not before (spec 21.7). -pub(super) async fn point(daemon: &Daemon, bot: &BotId, kind: BrowserActionKind, aim: &Aim) { +/// the cursor arrives and not before (spec 21.7). Says whether it waited. +pub(super) async fn point( + daemon: &Daemon, + bot: &BotId, + kind: BrowserActionKind, + aim: &Aim, +) -> bool { report(daemon, bot, kind, &aim.done); if daemon.browsers.watched(bot) { tokio::time::sleep(GLIDE).await; + return true; } + false } pub(super) fn report(daemon: &Daemon, bot: &BotId, kind: BrowserActionKind, done: &Done) { diff --git a/crates/botloftd/tests/browser.rs b/crates/botloftd/tests/browser.rs index eb80a62f..56925a4e 100644 --- a/crates/botloftd/tests/browser.rs +++ b/crates/botloftd/tests/browser.rs @@ -281,5 +281,79 @@ async fn the_owner_sees_the_cursor_get_there_before_the_click() { assert!(!clicking.is_finished()); let warned = clicking.await.expect("task").expect("clicked"); assert!(warned.contains("\"Hi there\""), "{warned}"); - assert!(pointed.elapsed() >= Duration::from_millis(500)); + assert!(pointed.elapsed() >= Duration::from_millis(250)); +} + +/// The first page of a site asks the owner; this answers and returns the page. +async fn open_page(b: &mut common::browsing::Browsing, path: &str) -> String { + let opening = call( + &b.mcp, + "browser_open", + json!({ "url": format!("{}{path}", b.site) }), + ); + answer_site(&mut b.app, true, None).await; + opening.await.expect("task").expect("page") +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_page_that_never_goes_quiet_does_not_cost_three_seconds_a_click() { + let Some(mut b) = setup().await else { return }; + let page = open_page(&mut b, "/poll").await; + let add = ref_of(&page, "button \"Add\""); + let mut after = String::new(); + for _ in 0..3 { + let started = Instant::now(); + after = b + .mcp + .tool_text("browser_click", json!({ "ref": add })) + .await + .expect("click"); + // It used to wait the whole 3 s for a network that never stops (spec + // 21.3), so never less than 3.15 s; a click now settles in under a + // second on a quiet machine (0.78 s measured) and took 2.2 s on the + // slow macOS runner, so the bound sits between the two. + assert!( + started.elapsed() < Duration::from_millis(2900), + "{:?}", + started.elapsed() + ); + } + assert!(after.contains("Count 3"), "{after}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_button_under_a_fixed_header_is_still_clicked() { + let Some(mut b) = setup().await else { return }; + + // On a tall page the header is avoided by scrolling the button to a free place. + let page = open_page(&mut b, "/scrolled").await; + let add = ref_of(&page, "button \"Add\""); + let after = b + .mcp + .tool_text("browser_click", json!({ "ref": add })) + .await + .expect("click"); + assert!(after.contains("Count 1"), "{after}"); + assert!(!after.contains("on top of the element"), "{after}"); + + // At the very top nothing scrolls it free: the page's own click does it, + // and says so, instead of clicking the header. + // The site is known now: no second question. + let page = b + .mcp + .tool_text( + "browser_open", + json!({ "url": format!("{}/cover", b.site) }), + ) + .await + .expect("cover"); + let add = ref_of(&page, "button \"Add\""); + let after = b + .mcp + .tool_text("browser_click", json!({ "ref": add })) + .await + .expect("click"); + assert!(after.contains("Count 1"), "{after}"); + assert!(after.contains("through the page's own script"), "{after}"); + assert!(!after.contains("Header clicked"), "{after}"); } diff --git a/crates/botloftd/tests/browser_panel.rs b/crates/botloftd/tests/browser_panel.rs index 0088ad25..eba3aa8a 100644 --- a/crates/botloftd/tests/browser_panel.rs +++ b/crates/botloftd/tests/browser_panel.rs @@ -9,7 +9,7 @@ use std::time::Duration; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use common::Client; -use common::browsing::{Browsing, answer_site, call, setup}; +use common::browsing::{Browsing, answer_site, call, ref_of, setup}; use serde_json::{Value, json}; /// The width and height of a JPEG, base64, from its frame header. @@ -134,33 +134,78 @@ async fn the_page_takes_the_shape_of_the_panel_while_the_owner_watches() { until_window(&mut b, "1280 x 800").await; } +/// The owner's screen is scaled above 100% (Windows at 125%, 150%, 200%): the +/// app says so in `browser.resize`. The page must stay in its own pixels and +/// every click, the bot's and the owner's, must land where it aimed. With a +/// sharper page the browser divided the clicks by the factor (spec 19). #[tokio::test(flavor = "multi_thread")] -async fn the_owner_sees_the_page_as_sharp_as_their_screen_and_the_bot_at_its_size() { +async fn a_scaled_screen_keeps_the_page_in_its_pixels_and_the_clicks_where_aimed() { let Some(mut b) = setup().await else { return }; let id = b.bot["id"].clone(); b.app .call("browser.watch", json!({ "botId": id })) .await .expect("watch"); - // A screen twice as sharp as the app's pixels. - let room = json!({ "botId": id, "width": 1000, "height": 700, "scale": 200 }); - b.app.call("browser.resize", room).await.expect("resize"); - let opening = call( - &b.mcp, - "browser_open", - json!({ "url": format!("{}/size", b.site) }), - ); - answer_site(&mut b.app, true, None).await; - let page = opening.await.expect("task").expect("page"); - // The page keeps its size; only its pixels double. - assert!(page.contains("Window: 1000 x 700, pixels 2"), "{page}"); - let frame = frame_of(&mut b.app, 700).await; - assert_eq!(frame["width"], 1000); - let data = frame["data"].as_str().expect("picture"); - assert_eq!(jpeg_size(data), (2000, 1400)); - - // The bot's own picture stays the size of the page, scrolled where the - // page is. + + for scale in [100u32, 125, 150, 200] { + let room = json!({ "botId": id, "width": 1000, "height": 700, "scale": scale }); + b.app.call("browser.resize", room).await.expect("resize"); + let opening = call( + &b.mcp, + "browser_open", + json!({ "url": format!("{}/size", b.site) }), + ); + if scale == 100 { + answer_site(&mut b.app, true, None).await; + } + let page = opening.await.expect("task").expect("page"); + // The page keeps its size and its pixels, whatever the screen asks. + assert!( + page.contains("Window: 1000 x 700, pixels 1"), + "{scale}%: {page}" + ); + let frame = frame_of(&mut b.app, 700).await; + assert_eq!( + jpeg_size(frame["data"].as_str().expect("picture")), + (1000, 700) + ); + + // The bot's click lands on the middle of the button, (350, 220). + let page = b + .mcp + .tool_text( + "browser_open", + json!({ "url": format!("{}/where", b.site) }), + ) + .await + .expect("where"); + let add = ref_of(&page, "button \"Add\""); + let clicked = b + .mcp + .tool_text("browser_click", json!({ "ref": add })) + .await + .expect("click"); + assert!(clicked.contains("Click 350,220"), "{scale}%: {clicked}"); + + // So does the owner's, at the point they aimed. + b.app + .call("browser.take", json!({ "botId": id })) + .await + .expect("take"); + common::browsing::click(&mut b.app, &b.bot, 360.0, 230.0).await; + b.app + .call("browser.release", json!({ "botId": id })) + .await + .expect("release"); + let seen = b + .mcp + .tool_text("browser_look", json!({})) + .await + .expect("look"); + assert!(seen.contains("Click 360,230"), "{scale}%: {seen}"); + } + + // The bot's own picture is the size of the page, scrolled where the page is. b.mcp .tool_text( "browser_open", diff --git a/crates/botloftd/tests/common/site.rs b/crates/botloftd/tests/common/site.rs index 5fbdb53d..691eee6b 100644 --- a/crates/botloftd/tests/common/site.rs +++ b/crates/botloftd/tests/common/site.rs @@ -62,6 +62,38 @@ const SIGNALS: &str = "Signals

b.brand).join(', ') + \ '; Agent: ' + navigator.userAgent + '; Outer: ' + outerWidth + ' x ' + outerHeight;"; +/// Says where the last mouse press landed, with a button at (300, 200), 100 by 40: +/// a click at its middle, (350, 220), must land there at any screen sharpness. +const WHERE: &str = "Where\ +

Click none

\ + \ + "; + +/// A page that never goes quiet, as one with an analytics script or a live +/// feed: a request every 100 ms, and a button that counts. +const POLL: &str = "Poll

Count 0

\ + \ + "; + +/// A fixed header over the button, which sits at the very top where the +/// page cannot scroll it out from under the header. +const COVER: &str = "Cover\ +
Header
\ +

Count 0

\ + "; + +/// A tall page with a fixed header and a button far down: scrolling it to the +/// middle of the screen leaves it free. +const SCROLLED: &str = "Scrolled\ +
Header

Count 0

\ +
\ + \ +
"; + async fn account(Form(form): Form>) -> Html { let user = form.get("user").cloned().unwrap_or_default(); let length = form @@ -107,6 +139,11 @@ pub async fn serve() -> SocketAddr { .route("/sight", get(|| async { Html(SIGHT) })) .route("/letters", get(|| async { Html(LETTERS) })) .route("/signals", get(|| async { Html(SIGNALS) })) + .route("/where", get(|| async { Html(WHERE) })) + .route("/poll", get(|| async { Html(POLL) })) + .route("/cover", get(|| async { Html(COVER) })) + .route("/scrolled", get(|| async { Html(SCROLLED) })) + .route("/ping", get(|| async { "ok" })) .route("/account", post(account)); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); diff --git a/docs/spec.md b/docs/spec.md index f6f25718..ae0fedfa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1148,7 +1148,8 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Revisão do MCP que o Claude Code usa | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25; o mesmo num servidor stdio | feito (M3) | | Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283** (interativo) e **2.1.284** (`-p --setting-sources project,local`): `Read(//c/.../**)` em `deny` bloqueou a leitura com "File is in a directory that is denied by your permission settings", sem perguntar, e o `result` listou a negação em `permission_denials` | feito (M4.1) | | Cercas de `Read` e `Edit` em `bypass_permissions` | 7.5 | **Testado com 2.1.287** (`-p --setting-sources project,local --permission-mode bypassPermissions`, Haiku): com `Read(//c/.../other/**)` e `Edit(//c/.../other/**)` em `deny` e a mesma pasta em `--add-dir`, o `Read` e o `Write` nela falharam com "File is in a directory that is denied by your permission settings", sem perguntar; os dois apareceram em `permission_denials` e o arquivo não foi criado | feito | -| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito Nitidez (visto com o Edge do Windows 11, outubro de 2026): com `deviceScaleFactor` 2 a página diz `devicePixelRatio` 2, mas os quadros do `Page.startScreencast` continuam nos pixels da página (1000 × 700 para uma página de 1000 × 700), com `maxWidth` folgado e até com o `scale` do `setDeviceMetricsOverride`; o `Page.captureScreenshot` vem com os pixels da tela (2000 × 1400). Com `clip` (`x`/`y` de `cssVisualViewport.pageX/pageY`, o tamanho da página e `scale` 0,5), ele volta a 1000 × 700 e mostra a parte para onde a página está rolada. | +| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito Nitidez (visto com o Edge do Windows 11, outubro de 2026): com `deviceScaleFactor` 2 a página diz `devicePixelRatio` 2, mas os quadros do `Page.startScreencast` continuam nos pixels da página (1000 × 700 para uma página de 1000 × 700), com `maxWidth` folgado e até com o `scale` do `setDeviceMetricsOverride`; o `Page.captureScreenshot` vem com os pixels da tela (2000 × 1400). Com `clip` (`x`/`y` de `cssVisualViewport.pageX/pageY`, o tamanho da página e `scale` 0,5), ele volta a 1000 × 700 e mostra a parte para onde a página está rolada. Na prática a página mais nítida perdia cliques (linha seguinte): hoje ela é sempre desenhada nos pixels dela. | +| Cliques com a página em escala acima de 1 | 21.3, 21.10 | **Medido com o Edge 154** (Windows 11, 2026-10-06, Edge sem janela de verdade): com `Emulation.setDeviceMetricsOverride` com `deviceScaleFactor` 1,25, 1,5 ou 2 e um `Page.captureScreenshot` a cada quadro, os cliques do bot (`Input.dispatchMouseEvent` num botão em (350, 220)) caíam em (280, 176), (233, 146) ou (175, 110), isto é, divididos pelo fator, em 8 de 12 combinações de tamanho e escala, e o mesmo ocorria com os cliques do dono; sem o `captureScreenshot` por quadro, os 12 acertavam, e a escala 1 sempre acerta. Segurar uma trava entre as fotos e os eventos de entrada não resolveu (11 de 12, depois pior): o navegador só lê as coordenadas certas sem a foto emulada. Por isso a página é desenhada sempre na escala 1. Também medido: um clique levava 0,17 s numa página calma, 3,2 s numa que nunca fica quieta (a espera pela rede) e 0,74 s com o painel aberto (a espera do cursor); depois das mudanças, 0,17 s, 0,78 s e 0,47 s. Um cabeçalho fixo sobre o botão fazia o bot clicar no cabeçalho (o contador não subia); hoje o botão é clicado por outro alinhamento ou, se nada dele fica livre, pelo clique da própria página | | O que um site vê do Edge sem janela | 21.3 | **Visto com o Edge 154** (Windows 11), com uma página que mostra os sinais: sem nada, `navigator.webdriver` é `true`, o user agent traz `HeadlessChrome` e `outerWidth` e `outerHeight` são 0. `Emulation.setUserAgentOverride` só com `userAgent` deixa `navigator.userAgentData.brands` vazio; com `userAgentMetadata` (o que `getHighEntropyValues` devolve, com os mesmos nomes), as marcas voltam. `about:blank` não tem `navigator.userAgentData` (não é página segura); `chrome://version` tem. `--user-agent` na linha de comando mantém as marcas, mas esvazia `fullVersionList`, `architecture` e `platformVersion`. `--disable-blink-features=AutomationControlled` deixa `navigator.webdriver` `false` mesmo com `--remote-debugging-port`. `--window-size` deixa a janela com o tamanho pedido (`Browser.getWindowForTarget`), mas `outerWidth` continua 0. O login do Google recusa o navegador ("Esse navegador ou app pode não ser seguro"), também com o dono no controle | feito | | Login numa janela de verdade | 21.11 | **Visto com o Edge 154** (Windows 11): `msedge.exe` com `--user-data-dir` e sem `--remote-debugging-port` fica vivo como o próprio navegador e sai alguns segundos depois de a janela fechar. Um cookie gravado nessa janela aparece no Edge sem janela aberto depois no mesmo perfil. **A verificar** com o Claude Code real: o dono entra numa conta do Google pela janela, fecha, e o bot abre o Gmail já dentro da conta; o bot que pediu ajuda lê que o navegador fechou e abre a página de novo | manual (PR) | | Imagem no resultado de uma tool MCP | 21.4 | Resultado com `{"type": "image", "data", "mimeType"}` em `content` (especificação MCP). **Testado com 2.1.284** (Haiku): pedido só pelas cores do logo no screenshot, o bot respondeu certo, e as cores não estavam no texto da página: o modelo recebe a imagem | feito | @@ -1370,11 +1371,11 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg ### 21.3 Página - **Abas:** o daemon se prende a toda página nova do navegador (`Target.setAutoAttach` no alvo do navegador, com `flatten` e `waitForDebuggerOnStart`) e a configura antes de ela rodar. Os comandos de preparo saem juntos com `Runtime.runIfWaitingForDebugger`, sem esperar resposta um a um: numa aba aberta pela página, `Page.enable` só responde depois que ela roda (visto com o Edge 154). O navegador só conta como aberto, e uma aba nova do dono só recebe um endereço, depois desse preparo: uma página aberta antes carregaria sem o daemon ver os eventos dela. Uma aba aberta pela página (`target=_blank`, `window.open`) vira a aba ativa, como para quem usa o navegador; quando ela fecha, a que era ativa antes dela volta (`Target.activateTarget`, para o navegador mostrar a mesma que o daemon). No máximo 6 abas: acima disso, fecha a que foi ativa há mais tempo. As tools agem sempre na ativa, e o bot não tem tool para trocar de aba; o dono, no controle, troca de aba, abre uma nova e vai a um endereço (21.10). O título de cada aba, para a lista que o dono vê (21.7), é o `document.title`, lido no mundo isolado (21.6) a cada carregamento e a cada mudança de endereço dentro da página: o Edge sem janela avisa quando a aba ganha um endereço, mas nunca o título dela (19). Até a página carregar, a aba fica sem título; um título que a página troca depois disso só aparece no carregamento seguinte. -- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que ela tem na mesa do painel (`browser.resize`, 21.7), medido onde ela aparece, por dentro da borda da mesa, já sem os avisos e os botões em volta: assim ela o ocupa inteiro, sem faixas da mesa dos lados. Um aviso que aparece ou some (o pedido de ajuda, as notas do controle) muda esse espaço, e a página acompanha. A largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. Para o dono, a página também tem a nitidez da tela dele (`deviceScaleFactor` igual ao `devicePixelRatio` do app, de 1 a 2): numa tela com escala de 150% ou com o app ampliado, o texto não fica borrado. O tamanho da página em pixels CSS não muda com isso, e as fotos que o bot tira (`browser_screenshot`) continuam do tamanho da página: são recortadas onde a página está rolada (`Page.getLayoutMetrics`) e reduzidas (`clip.scale`), para não custarem mais ao bot. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). +- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que ela tem na mesa do painel (`browser.resize`, 21.7), medido onde ela aparece, por dentro da borda da mesa, já sem os avisos e os botões em volta: assim ela o ocupa inteiro, sem faixas da mesa dos lados. Um aviso que aparece ou some (o pedido de ajuda, as notas do controle) muda esse espaço, e a página acompanha. A largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. A página é desenhada sempre nos pixels dela (`deviceScaleFactor` 1), qualquer que seja a escala da tela do dono: o painel amplia o quadro. Uma versão desenhava a página tão nítida quanto a tela (de 1 a 2); numa tela com escala acima de 100% (o normal no Windows, 125% a 200%) os cliques do bot e do dono eram lidos pelo navegador divididos por esse fator e caíam no ponto errado ou se perdiam (19). `browser.resize` ainda aceita `scale` e o ignora. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). - **Diálogos** (`alert`, `confirm`, `prompt`, "sair da página?"): aceitos na hora; a resposta seguinte da tool diz ao bot o texto que apareceu. - **Escolher arquivo:** o pedido da página é interceptado e cancelado, e o bot lê que enviar arquivos pelo navegador ainda não é possível. - **Downloads:** vão para `\downloads\` (`Browser.setDownloadBehavior`) e aparecem no painel de arquivos (8.4); a tool diz quando um download termina. -- **Esperar a página:** depois de cada ação, o daemon espera a navegação que ela começou terminar (`Page.loadEventFired`), até 15 s, e a rede ficar quieta (nenhum request pendente por 500 ms), até mais 3 s. Uma página que nunca sossega não prende o bot: ele recebe o que já carregou. +- **Esperar a página:** depois de cada ação, o daemon espera a navegação que ela começou terminar (`Page.loadEventFired`), até 15 s, e a rede ficar quieta (nenhum request pendente por 500 ms), até mais 3 s, ou até mais 0,6 s numa página que já estava com a rede ocupada antes da ação: o que ela faz sozinha (um script de estatísticas, um chat, um feed) não vai parar por causa do clique, e esperar por isso custava 3 s a cada ação (19). Uma página que nunca sossega não prende o bot: ele recebe o que já carregou. ### 21.4 Tools @@ -1384,7 +1385,7 @@ Ficam no servidor `botloft` (11), então `--allowedTools mcp__botloft` as libera |---|---|---| | `browser_open` | `url` | abre um endereço `http` ou `https`, ou um arquivo das pastas do bot (21.5): caminho do Windows, relativo à pasta do bot, ou `file:///` | | `browser_look` | `from?` | lê a página de novo, sem agir; `from` continua o texto de onde a leitura anterior cortou | -| `browser_click` | `ref` | clica no elemento, no meio dele, com o mouse (rola até ele antes) | +| `browser_click` | `ref` | clica no elemento, no meio dele, com o mouse (rola até ele antes). Se algo (um cabeçalho fixo, um aviso, um chat) cobre o meio, tenta os outros alinhamentos da rolagem e outros pontos do elemento; se nada dele fica livre, usa o clique da própria página e diz isso ao bot | | `browser_type` | `ref`, `text`, `submit?` | clica no campo, troca o que havia pelo texto e, com `submit`, aperta Enter | | `browser_select` | `ref`, `option` | escolhe uma opção de um `