Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make the browser click/hover steps resilient to DOM nodes detaching #11

Merged
merged 1 commit into from
Dec 12, 2024
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

## Unreleased

* Made the browser click/hover steps more resilient to DOM nodes detaching mid-action

## v0.10.0 (December 12, 2024)

* Add `browser-timeout` / `browser_timeout` setting that changes the default timeout for browser actions such as `toolproof.querySelector()`
Expand Down
236 changes: 148 additions & 88 deletions toolproof/src/definitions/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use chromiumoxide::cdp::browser_protocol::page::{
use chromiumoxide::cdp::browser_protocol::target::{
CreateBrowserContextParams, CreateTargetParams,
};
use chromiumoxide::cdp::js_protocol::runtime::RemoteObjectType;
use chromiumoxide::error::CdpError;
use chromiumoxide::handler::viewport::Viewport;
use chromiumoxide::page::ScreenshotParams;
Expand Down Expand Up @@ -277,69 +278,95 @@ impl BrowserWindow {
};
let xpath = [el_xpath("a"), el_xpath("button"), el_xpath("input")].join(" | ");

let elements = browser_specific::wait_for_chrome_xpath_selectors(
page,
&xpath,
&format!("with text '{text}'"),
timeout_secs,
)
.await?;

if elements.is_empty() {
return Err(ToolproofStepError::Assertion(
ToolproofTestFailure::Custom {
msg: format!(
"Clickable element containing text '{text}' does not exist."
),
},
));
}
loop {
let elements = browser_specific::wait_for_chrome_xpath_selectors(
page,
&xpath,
&format!("with text '{text}'"),
timeout_secs,
)
.await?;

if elements.is_empty() {
return Err(ToolproofStepError::Assertion(
ToolproofTestFailure::Custom {
msg: format!(
"Clickable element containing text '{text}' does not exist."
),
},
));
}

if elements.len() > 1 {
return Err(ToolproofStepError::Assertion(
ToolproofTestFailure::Custom {
msg: format!(
if elements.len() > 1 {
return Err(ToolproofStepError::Assertion(
ToolproofTestFailure::Custom {
msg: format!(
"Found more than one clickable element containing text '{text}'."
),
},
));
}

elements[0].scroll_into_view().await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!(
"Element with text '{text}' could not be scrolled into view: {e}"
),
})
})?;

let center = elements[0].clickable_point().await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!(
"Could not find a clickable point for element with text '{text}': {e}"
),
})
})?;
},
));
}

match interaction {
InteractionType::Click => {
page.click(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!(
"Element with text '{text}' could not be clicked: {e}"
),
})
})?;
if let Err(e) = elements[0].scroll_into_view().await {
match e {
// If the element was detached from the DOM after the time we selected it,
// we want to restart this section and select a new element.
CdpError::ScrollingFailed(msg) if msg.contains("detached") => continue,
_ => {
return Err(ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!(
"Element with text '{text}' could not be scrolled into view: {e}"
),
}))
}
}
}
InteractionType::Hover => {
page.move_mouse(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {

let center = match elements[0].clickable_point().await {
Ok(c) => c,
Err(e) => {
if let Ok(res) = elements[0]
.call_js_fn("async function() { return this.isConnected; }", true)
.await
{
// If we can't find the center due to the element now being detached from the DOM,
// we want to restart this section and select a new element.
if matches!(res.result.value, Some(serde_json::Value::Bool(false)))
{
continue;
}
}

return Err(ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!(
"Element with text '{text}' could not be hovered: {e}"
),
})
})?;
"Could not find a clickable point for element with text '{text}': {e}"
),
}));
}
};

match interaction {
InteractionType::Click => {
page.click(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!(
"Element with text '{text}' could not be clicked: {e}"
),
})
})?;
}
InteractionType::Hover => {
page.move_mouse(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!(
"Element with text '{text}' could not be hovered: {e}"
),
})
})?;
}
}

break;
}

Ok(())
Expand All @@ -360,40 +387,73 @@ impl BrowserWindow {
) -> Result<(), ToolproofStepError> {
match self {
BrowserWindow::Chrome(page) => {
let element = browser_specific::wait_for_chrome_element_selector(
page,
selector,
timeout_secs,
)
.await?;

element.scroll_into_view().await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element {selector} could not be scrolled into view: {e}"),
})
})?;

let center = element.clickable_point().await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Could not find a clickable point for {selector}: {e}"),
})
})?;

match interaction {
InteractionType::Click => {
page.click(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element {selector} could not be clicked: {e}"),
})
})?;
loop {
let element = browser_specific::wait_for_chrome_element_selector(
page,
selector,
timeout_secs,
)
.await?;

if let Err(e) = element.scroll_into_view().await {
match e {
// If the element was detached from the DOM after the time we selected it,
// we want to restart this section and select a new element.
CdpError::ScrollingFailed(msg) if msg.contains("detached") => continue,
_ => {
return Err(ToolproofStepError::Assertion(
ToolproofTestFailure::Custom {
msg: format!(
"Element {selector} could not be scrolled into view: {e}"
),
},
))
}
}
}
InteractionType::Hover => {
page.move_mouse(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element {selector} could not be hovered: {e}"),
})
})?;

let center = match element.clickable_point().await {
Ok(c) => c,
Err(e) => {
if let Ok(res) = element
.call_js_fn("async function() { return this.isConnected; }", true)
.await
{
// If we can't find the center due to the element now being detached from the DOM,
// we want to restart this section and select a new element.
if matches!(res.result.value, Some(serde_json::Value::Bool(false)))
{
continue;
}
}

return Err(ToolproofStepError::Assertion(
ToolproofTestFailure::Custom {
msg: format!(
"Could not find a clickable point for {selector}: {e}"
),
},
));
}
};

match interaction {
InteractionType::Click => {
page.click(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element {selector} could not be clicked: {e}"),
})
})?;
}
InteractionType::Hover => {
page.move_mouse(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element {selector} could not be hovered: {e}"),
})
})?;
}
}
break;
}

Ok(())
Expand Down
Loading