Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ fn every_plain_action_names_its_engine_command() {
(
Action::Press {
key: "Enter".to_owned(),
target: None,
},
"press",
),
Expand All @@ -117,14 +118,32 @@ fn every_plain_action_names_its_engine_command() {
assert!(
invalid(action(
&Action::Press {
key: " ".to_owned()
key: " ".to_owned(),
target: None,
},
1_000
))
.contains("key")
);
}

#[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(
Expand Down
6 changes: 4 additions & 2 deletions crates/tinycomputer-browser/src/convert/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ pub(crate) fn action(action: &Action, default_timeout_ms: u64) -> Result<Value>
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"));
}
Expand Down Expand Up @@ -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<String> {
pub(crate) fn selector(target: &Target, operation: &str) -> Result<String> {
match target {
Target::Ref { value } if !value.trim().is_empty() => {
Ok(format!("@{}", value.trim_start_matches('@')))
Expand Down
2 changes: 1 addition & 1 deletion crates/tinycomputer-browser/src/convert/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
23 changes: 23 additions & 0 deletions crates/tinycomputer-browser/src/error/error_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ fn every_variant() -> Vec<Error> {
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(),
Expand Down Expand Up @@ -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")),
);
}
15 changes: 14 additions & 1 deletion crates/tinycomputer-browser/src/error/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
83 changes: 80 additions & 3 deletions crates/tinycomputer-browser/src/fake/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,32 @@ use crate::engine::{Engine, Launcher, Reply};
type Script = dyn Fn(&Value) -> Option<Value> + 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<Mutex<Vec<Value>>>,
script: Arc<Script>,
stall: Arc<Stall>,
focus: PageFocus,
}

impl std::fmt::Debug for Fake {
Expand All @@ -39,9 +58,17 @@ impl Fake {
sent: Arc::new(Mutex::new(Vec::new())),
script: Arc::new(script),
stall: Arc::new(|_| false),
focus: PageFocus::OnTarget,
}
}

/// The same fake, its page putting the focus where `focus` says once an
/// element is given it.
pub(crate) fn with_focus(mut self, focus: PageFocus) -> Self {
self.focus = focus;
self
}

/// The same fake, never answering a command `stall` picks, as a page
/// being replaced may not.
pub(crate) fn stalling(
Expand Down Expand Up @@ -149,11 +176,61 @@ impl Engine for Fake {
if (self.stall)(&command) {
return Box::pin(std::future::pending());
}
let reply = (self.script)(&command).unwrap_or_else(|| default_reply(&command));
let reply = self
.focus_reply(&command)
.or_else(|| (self.script)(&command))
.unwrap_or_else(|| default_reply(&command));
Box::pin(async move { reply })
}
}

impl Fake {
/// The page's answer to the focus check (`sessions/focus.rs`): where
/// the marking script finds the focus, and the target's mark.
fn focus_reply(&self, command: &Value) -> Option<Value> {
let script = command["script"].as_str().unwrap_or_default();
if command["action"] == "evaluate" && script.contains("setAttribute('data-tc-focus'") {
let found = match self.focus {
PageFocus::OnTarget | PageFocus::Stale => {
json!({"focus": "element", "on": "input"})
}
PageFocus::Elsewhere(tag) => json!({"focus": "element", "on": tag}),
PageFocus::Nowhere => json!({"focus": "none", "on": "nothing"}),
PageFocus::InAFrame => json!({"focus": "frame", "on": "a frame"}),
};
return Some(ok(&json!({"result": found})));
}
if command["action"] == "getattribute" && command["attribute"] == "data-tc-focus" {
let target = command["selector"].as_str().unwrap_or_default();
return Some(match self.focus {
PageFocus::OnTarget => {
let sent = self.sent.lock().unwrap();
let mark = sent
.iter()
.rev()
.find_map(|earlier| focus_mark(earlier["script"].as_str()?));
ok(&json!({"value": mark}))
}
PageFocus::Stale => {
failure(&format!("Unknown ref: {}", target.trim_start_matches('@')))
}
_ => ok(&json!({"value": null})),
});
}
None
}
}

/// The mark value a focus-marking script was called with, if `script` is one.
pub(crate) fn focus_mark(script: &str) -> Option<String> {
if !script.contains("setAttribute('data-tc-focus'") {
return None;
}
let start = script.rfind("(\"tc-")? + 2;
let end = start + script[start..].find('"')?;
Some(script[start..end].to_owned())
}

impl Launcher for Fake {
fn open(&self, _session: &str) -> Box<dyn Engine> {
Box::new(self.clone())
Expand Down
Loading
Loading