Skip to content

Commit

Permalink
Merge pull request #8 from CloudCannon/feat/interactions-and-timeouts
Browse files Browse the repository at this point in the history
Add timeouts to steps, add element click+hover instructions
  • Loading branch information
bglw authored Dec 3, 2024
2 parents 2238ae7 + 1cf1ece commit 1550e45
Show file tree
Hide file tree
Showing 6 changed files with 406 additions and 23 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

## Unreleased

* Add instructions for clicking and hovering elements on a page
* Added a timeout to all test steps

## v0.7.0 (November 29, 2024)

* Add screenshot instructions to Toolproof
Expand Down
4 changes: 4 additions & 0 deletions docs/content/docs/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Instructions:
- `In my browser, I evaluate {js}`
- `In my browser, I screenshot the viewport to {filepath}`
- `In my browser, I screenshot the element {selector} to {filepath}`
- `In my browser, I click {text}`
- `In my browser, I hover {text}`
- `In my browser, I click the selector {selector}`
- `In my browser, I hover the selector {selector}`

Retrievals:
- `In my browser, the result of {js}`
Expand Down
291 changes: 289 additions & 2 deletions toolproof/src/definitions/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ use async_trait::async_trait;
use chromiumoxide::cdp::browser_protocol::page::{
CaptureScreenshotFormat, CaptureScreenshotParams,
};
use chromiumoxide::cdp::browser_protocol::target::CreateTargetParams;
use chromiumoxide::cdp::browser_protocol::target::{
CreateBrowserContextParams, CreateTargetParams,
};
use chromiumoxide::error::CdpError;
use chromiumoxide::handler::viewport::Viewport;
use chromiumoxide::page::ScreenshotParams;
use futures::StreamExt;
use tempfile::tempdir;
use tokio::task::JoinHandle;

use crate::civilization::Civilization;
Expand Down Expand Up @@ -45,6 +48,7 @@ async fn try_launch_browser(mut max: usize) -> (Browser, chromiumoxide::Handler)
launch = Browser::launch(
BrowserConfig::builder()
.headless_mode(chromiumoxide::browser::HeadlessMode::New)
.user_data_dir(tempdir().expect("testing on a system with a temp dir"))
.viewport(Some(Viewport {
width: 1600,
height: 900,
Expand Down Expand Up @@ -89,6 +93,11 @@ fn chrome_image_format(filepath: &PathBuf) -> Result<CaptureScreenshotFormat, To
}
}

enum InteractionType {
Click,
Hover,
}

impl BrowserTester {
async fn initialize(params: &ToolproofParams) -> Self {
match params.browser {
Expand Down Expand Up @@ -127,13 +136,22 @@ impl BrowserTester {
BrowserWindow::Pagebrowse(pb.get_window().await.unwrap())
}
BrowserTester::Chrome { browser, .. } => {
let context = browser
.create_browser_context(CreateBrowserContextParams {
dispose_on_detach: Some(true),
proxy_server: None,
proxy_bypass_list: None,
origins_with_universal_network_access: None,
})
.await
.unwrap();
let page = browser
.new_page(CreateTargetParams {
url: "about:blank".to_string(),
for_tab: None,
width: None,
height: None,
browser_context_id: None,
browser_context_id: Some(context),
enable_begin_frame_control: None,
new_window: None,
background: None,
Expand Down Expand Up @@ -247,6 +265,143 @@ impl BrowserWindow {
)),
}
}

async fn interact_text(
&self,
text: &str,
interaction: InteractionType,
) -> Result<(), ToolproofStepError> {
match self {
BrowserWindow::Chrome(page) => {
let text = text.to_lowercase().replace('\'', "\\'");
let el_xpath = |el: &str| {
format!("//{el}[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{text}')]")
};
let xpath = [el_xpath("a"), el_xpath("button"), el_xpath("input")].join(" | ");
let elements = page.find_xpaths(xpath).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element with text '{text}' could not be clicked: {e}"),
})
})?;

if elements.is_empty() {
return Err(ToolproofStepError::Assertion(
ToolproofTestFailure::Custom {
msg: format!(
"Clickable element containing text '{text}' does not exist. Did you mean to use 'I click the selector'?"
),
},
));
}

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}"
),
})
})?;
}
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}"
),
})
})?;
}
}

Ok(())
}
BrowserWindow::Pagebrowse(_) => Err(ToolproofStepError::Internal(
ToolproofInternalError::Custom {
msg: "Clicks not yet implemented for Pagebrowse".to_string(),
},
)),
}
}

async fn interact_selector(
&self,
selector: &str,
interaction: InteractionType,
) -> Result<(), ToolproofStepError> {
match self {
BrowserWindow::Chrome(page) => {
let element = page.find_element(selector).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element {selector} could not be clicked: {e}"),
})
})?;

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}"),
})
})?;
}
InteractionType::Hover => {
page.move_mouse(center).await.map_err(|e| {
ToolproofStepError::Assertion(ToolproofTestFailure::Custom {
msg: format!("Element {selector} could not be hovered: {e}"),
})
})?;
}
}

Ok(())
}
BrowserWindow::Pagebrowse(_) => Err(ToolproofStepError::Internal(
ToolproofInternalError::Custom {
msg: "Clicks not yet implemented for Pagebrowse".to_string(),
},
)),
}
}
}

mod load_page {
Expand Down Expand Up @@ -493,3 +648,135 @@ mod screenshots {
}
}
}

mod interactions {
use super::*;

pub struct ClickText;

inventory::submit! {
&ClickText as &dyn ToolproofInstruction
}

#[async_trait]
impl ToolproofInstruction for ClickText {
fn segments(&self) -> &'static str {
"In my browser, I click {text}"
}

async fn run(
&self,
args: &SegmentArgs<'_>,
civ: &mut Civilization,
) -> Result<(), ToolproofStepError> {
let text = args.get_string("text")?;

let Some(window) = civ.window.as_ref() else {
return Err(ToolproofStepError::External(
ToolproofInputError::StepRequirementsNotMet {
reason: "no page has been loaded into the browser for this test".into(),
},
));
};

window.interact_text(&text, InteractionType::Click).await
}
}

pub struct HoverText;

inventory::submit! {
&HoverText as &dyn ToolproofInstruction
}

#[async_trait]
impl ToolproofInstruction for HoverText {
fn segments(&self) -> &'static str {
"In my browser, I hover {text}"
}

async fn run(
&self,
args: &SegmentArgs<'_>,
civ: &mut Civilization,
) -> Result<(), ToolproofStepError> {
let text = args.get_string("text")?;

let Some(window) = civ.window.as_ref() else {
return Err(ToolproofStepError::External(
ToolproofInputError::StepRequirementsNotMet {
reason: "no page has been loaded into the browser for this test".into(),
},
));
};

window.interact_text(&text, InteractionType::Hover).await
}
}

pub struct ClickSelector;

inventory::submit! {
&ClickSelector as &dyn ToolproofInstruction
}

#[async_trait]
impl ToolproofInstruction for ClickSelector {
fn segments(&self) -> &'static str {
"In my browser, I click the selector {selector}"
}

async fn run(
&self,
args: &SegmentArgs<'_>,
civ: &mut Civilization,
) -> Result<(), ToolproofStepError> {
let selector = args.get_string("selector")?;

let Some(window) = civ.window.as_ref() else {
return Err(ToolproofStepError::External(
ToolproofInputError::StepRequirementsNotMet {
reason: "no page has been loaded into the browser for this test".into(),
},
));
};

window
.interact_selector(&selector, InteractionType::Click)
.await
}
}

pub struct HoverSelector;

inventory::submit! {
&HoverSelector as &dyn ToolproofInstruction
}

#[async_trait]
impl ToolproofInstruction for HoverSelector {
fn segments(&self) -> &'static str {
"In my browser, I hover the selector {selector}"
}

async fn run(
&self,
args: &SegmentArgs<'_>,
civ: &mut Civilization,
) -> Result<(), ToolproofStepError> {
let selector = args.get_string("selector")?;

let Some(window) = civ.window.as_ref() else {
return Err(ToolproofStepError::External(
ToolproofInputError::StepRequirementsNotMet {
reason: "no page has been loaded into the browser for this test".into(),
},
));
};

window
.interact_selector(&selector, InteractionType::Hover)
.await
}
}
}
16 changes: 16 additions & 0 deletions toolproof/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,13 @@ fn get_cli_matches() -> ArgMatches {
)
.action(clap::ArgAction::SetTrue),
)
.arg(
arg!(
--timeout <NUM> "How long in seconds until a step times out"
)
.required(false)
.value_parser(value_parser!(u64)),
)
.arg(
arg!(
-n --name <NAME> "Exact name of a test to run")
Expand Down Expand Up @@ -176,6 +183,11 @@ pub struct ToolproofParams {
#[setting(default = 10)]
pub concurrency: usize,

/// How long in seconds until a step times out
#[setting(env = "TOOLPROOF_TIMEOUT")]
#[setting(default = 10)]
pub timeout: u64,

/// What delimiter should be used when replacing placeholders
#[setting(env = "TOOLPROOF_PLACEHOLDER_DELIM")]
#[setting(default = "%")]
Expand Down Expand Up @@ -250,6 +262,10 @@ impl ToolproofParams {
self.concurrency = *concurrency;
}

if let Some(timeout) = cli_matches.get_one::<u64>("timeout") {
self.timeout = *timeout;
}

if let Some(placeholder_delimiter) = cli_matches.get_one::<String>("placeholder-delimiter")
{
self.placeholder_delimiter = placeholder_delimiter.clone();
Expand Down
Loading

0 comments on commit 1550e45

Please sign in to comment.