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

Add timeouts to steps, add element click+hover instructions #8

Merged
merged 1 commit into from
Dec 3, 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
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
Loading