diff --git a/crates/tinycomputer-browser/src/convert/convert_tests/interaction_tests.rs b/crates/tinycomputer-browser/src/convert/convert_tests/interaction_tests.rs index 490c4705..d269e0ce 100644 --- a/crates/tinycomputer-browser/src/convert/convert_tests/interaction_tests.rs +++ b/crates/tinycomputer-browser/src/convert/convert_tests/interaction_tests.rs @@ -103,6 +103,7 @@ fn every_plain_action_names_its_engine_command() { ( Action::Press { key: "Enter".to_owned(), + target: None, }, "press", ), @@ -117,7 +118,8 @@ fn every_plain_action_names_its_engine_command() { assert!( invalid(action( &Action::Press { - key: " ".to_owned() + key: " ".to_owned(), + target: None, }, 1_000 )) @@ -125,6 +127,23 @@ fn every_plain_action_names_its_engine_command() { ); } +#[test] +fn a_press_on_a_target_is_the_plain_key_the_focus_check_precedes() { + // `Browser::perform` gives the target the focus and checks it first; the + // key itself goes to the focus, as every press does. + assert_eq!( + action( + &Action::Press { + key: "Enter".to_owned(), + target: Some(Target::reference("e4")), + }, + 1_000 + ) + .unwrap(), + json!({"action": "press", "key": "Enter"}) + ); +} + #[test] fn typing_with_and_without_a_target() { let focused = action( diff --git a/crates/tinycomputer-browser/src/convert/interaction.rs b/crates/tinycomputer-browser/src/convert/interaction.rs index 03fc7b44..862004f1 100644 --- a/crates/tinycomputer-browser/src/convert/interaction.rs +++ b/crates/tinycomputer-browser/src/convert/interaction.rs @@ -35,7 +35,9 @@ pub(crate) fn action(action: &Action, default_timeout_ms: u64) -> Result Action::Back => Ok(json!({"action": "back"})), Action::Forward => Ok(json!({"action": "forward"})), Action::Reload => Ok(json!({"action": "reload"})), - Action::Press { key } => { + // A press goes wherever the focus is: with a target, `Browser::perform` + // first puts the focus there and checks it landed. + Action::Press { key, .. } => { if key.trim().is_empty() { return Err(Error::invalid_input("press needs a key")); } @@ -168,7 +170,7 @@ fn wait_for( /// /// [`Error::InvalidInput`] for a locator, which agent-browser only accepts on /// its semantic commands (click, fill, check, hover, text). -pub(super) fn selector(target: &Target, operation: &str) -> Result { +pub(crate) fn selector(target: &Target, operation: &str) -> Result { match target { Target::Ref { value } if !value.trim().is_empty() => { Ok(format!("@{}", value.trim_start_matches('@'))) diff --git a/crates/tinycomputer-browser/src/convert/mod.rs b/crates/tinycomputer-browser/src/convert/mod.rs index 8fd94a34..1d152837 100644 --- a/crates/tinycomputer-browser/src/convert/mod.rs +++ b/crates/tinycomputer-browser/src/convert/mod.rs @@ -13,7 +13,7 @@ mod interaction; mod page; mod session; -pub(crate) use interaction::action; +pub(crate) use interaction::{action, selector}; pub(crate) use page::{evaluate, navigate, read, screenshot, snapshot}; pub(crate) use session::{launch, viewport}; diff --git a/crates/tinycomputer-browser/src/error/error_tests.rs b/crates/tinycomputer-browser/src/error/error_tests.rs index 5339d84f..89958115 100644 --- a/crates/tinycomputer-browser/src/error/error_tests.rs +++ b/crates/tinycomputer-browser/src/error/error_tests.rs @@ -103,6 +103,9 @@ fn every_variant() -> Vec { reference: "e1".to_string(), }, Error::not_actionable("covered by a banner"), + Error::FocusMissed { + focused: "input".to_string(), + }, Error::timeout("navigate", 30_000), Error::BlockedByPolicy { url: "https://evil.test/".to_string(), @@ -239,3 +242,23 @@ fn a_lost_connection_suggests_a_new_session() { .is_some_and(|s| s.contains("BrowserOpenSession")) ); } + +#[test] +fn a_missed_focus_is_not_actionable_and_says_to_act_on_the_field() { + let error = Error::FocusMissed { + focused: "input".to_string(), + }; + assert_eq!(error.wire_name(), errors::NOT_ACTIONABLE); + assert!(errors::is_agent_recoverable(error.wire_name())); + let envelope = error.envelope(); + assert!( + envelope.message.contains("it is on input"), + "{}", + envelope.message + ); + assert!( + envelope + .suggestion + .is_some_and(|hint| hint.contains("act on the field itself")), + ); +} diff --git a/crates/tinycomputer-browser/src/error/mod.rs b/crates/tinycomputer-browser/src/error/mod.rs index e6728649..258409d3 100644 --- a/crates/tinycomputer-browser/src/error/mod.rs +++ b/crates/tinycomputer-browser/src/error/mod.rs @@ -60,6 +60,16 @@ pub enum Error { reason: String, }, + /// The element did not take the keyboard focus, so nothing was typed or + /// pressed: the keys would have gone to whatever held the focus instead, + /// such as a field filled earlier or a button. + #[error("the element did not take the keyboard focus (it is on {focused}), nothing was sent")] + FocusMissed { + /// What holds the focus instead: a tag name, such as `input`, or + /// `nothing`. + focused: String, + }, + /// An operation ran out of time. #[error("{operation} timed out after {elapsed_ms}ms")] Timeout { @@ -156,7 +166,7 @@ impl Error { Self::NoSuchSession { .. } | Self::ConnectionLost { .. } => errors::NO_SUCH_SESSION, Self::NoSuchElement { .. } => errors::NO_SUCH_ELEMENT, Self::StaleRef { .. } => errors::STALE_REF, - Self::NotActionable { .. } => errors::NOT_ACTIONABLE, + Self::NotActionable { .. } | Self::FocusMissed { .. } => errors::NOT_ACTIONABLE, Self::Timeout { .. } => errors::TIMEOUT, Self::BlockedByPolicy { .. } | Self::LeftRefusedPage { .. } => { errors::BLOCKED_BY_POLICY @@ -215,6 +225,9 @@ impl Error { Self::NotActionable { .. } => { Some("dismiss whatever covers the element, or scroll it into view, then retry") } + Self::FocusMissed { .. } => Some( + "act on the field itself (an input, a text area, or an editable region), not its label or heading; take a fresh BrowserSnapshot to find it", + ), Self::NoSuchSession { .. } | Self::ConnectionLost { .. } => { Some("open a new session with BrowserOpenSession") } diff --git a/crates/tinycomputer-browser/src/fake/mod.rs b/crates/tinycomputer-browser/src/fake/mod.rs index a163555f..8ab5ec21 100644 --- a/crates/tinycomputer-browser/src/fake/mod.rs +++ b/crates/tinycomputer-browser/src/fake/mod.rs @@ -12,13 +12,32 @@ use crate::engine::{Engine, Launcher, Reply}; type Script = dyn Fn(&Value) -> Option + Send + Sync; type Stall = dyn Fn(&Value) -> bool + Send + Sync; -/// Records every command; answers from an optional override, else like the -/// engine would, and never answers a command it is told to stall on. +/// Where a page puts the focus once an element is given it, as the focus +/// check (`sessions/focus.rs`) reads it. +#[derive(Clone, Copy, Debug)] +pub(crate) enum PageFocus { + /// On the element: every element named takes it. The default. + OnTarget, + /// On another element, by its tag. + Elsewhere(&'static str), + /// On nothing: the element took none, and nothing else holds it. + Nowhere, + /// Inside a cross-origin frame, which the page cannot see into. + InAFrame, + /// On the element, whose ref the engine no longer resolves when asked + /// for its mark. + Stale, +} + +/// Records every command; answers the focus check as its page's focus says, +/// the rest from an optional override, else like the engine would, and never +/// answers a command it is told to stall on. #[derive(Clone)] pub(crate) struct Fake { sent: Arc>>, script: Arc