├── clippy.toml ├── tests ├── test_html │ ├── globe.html │ ├── iframe_inner.html │ ├── redirect_test.html │ ├── iframe_outer.html │ ├── other_page.html │ └── sample_page.html ├── remote.rs ├── alert.rs ├── elements.rs ├── actions.rs └── common.rs ├── .gitignore ├── ci ├── macos-latest-firefox ├── ubuntu-latest-firefox ├── ubuntu-latest-chrome ├── macos-latest-chrome ├── windows-latest-chrome.ps1 └── windows-latest-firefox.ps1 ├── .codecov.yml ├── .github ├── codecov.yml ├── dependabot.yml ├── DOCS.md └── workflows │ ├── scheduled.yml │ ├── check.yml │ └── test.yml ├── LICENSE-MIT ├── examples ├── wait.rs └── basic.rs ├── Cargo.toml ├── README.md ├── src ├── wait.rs ├── key.rs ├── cookies.rs ├── wd.rs ├── lib.rs ├── print.rs ├── actions.rs ├── elements.rs └── error.rs └── LICENSE-APACHE /clippy.toml: -------------------------------------------------------------------------------- 1 | doc-valid-idents = ["WebDriver"] 2 | -------------------------------------------------------------------------------- /tests/test_html/globe.html: -------------------------------------------------------------------------------- 1 | This is a globe 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | **/*.rs.bk 3 | /.idea 4 | *.iml 5 | -------------------------------------------------------------------------------- /ci/macos-latest-firefox: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | brew install --cask firefox 3 | brew install geckodriver 4 | geckodriver & 5 | -------------------------------------------------------------------------------- /ci/ubuntu-latest-firefox: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sudo apt-get update 3 | sudo apt-get install firefox-geckodriver 4 | geckodriver & 5 | -------------------------------------------------------------------------------- /ci/ubuntu-latest-chrome: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sudo apt-get update 3 | sudo apt-get install chromium-chromedriver 4 | chromedriver --port=9515 & 5 | -------------------------------------------------------------------------------- /ci/macos-latest-chrome: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | brew install --cask google-chrome 3 | brew install --cask chromedriver 4 | chromedriver --port=9515 & 5 | -------------------------------------------------------------------------------- /ci/windows-latest-chrome.ps1: -------------------------------------------------------------------------------- 1 | npm i puppeteer 2 | npx @puppeteer/browsers install chrome@stable 3 | npx @puppeteer/browsers install chromedriver@stable 4 | Start-Process -FilePath chromedriver -ArgumentList "--port=9515" 5 | Start-Sleep -Seconds 1 6 | -------------------------------------------------------------------------------- /tests/test_html/iframe_inner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Iframe Inner 6 | 7 | 8 |
9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/test_html/redirect_test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | This Page will Redirect 6 | 7 | 8 | This page will redirect to https://wikipedia.org 9 | 10 | 11 | -------------------------------------------------------------------------------- /ci/windows-latest-firefox.ps1: -------------------------------------------------------------------------------- 1 | choco install firefox 2 | Invoke-WebRequest -Uri "https://github.com/mozilla/geckodriver/releases/download/v0.36.0/geckodriver-v0.36.0-win64.zip" -OutFile geckodriver.zip 3 | Expand-Archive -LiteralPath geckodriver.zip -DestinationPath . 4 | Start-Process -FilePath geckodriver 5 | Start-Sleep -Seconds 1 6 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 90..100 # set a high standard for ourselves 3 | round: down 4 | precision: 2 5 | status: 6 | project: 7 | default: 8 | threshold: 1% 9 | ignore: 10 | - "ci" 11 | - "tests" 12 | # Make less noisy comments 13 | comment: 14 | layout: "files" 15 | require_changes: yes 16 | -------------------------------------------------------------------------------- /tests/test_html/iframe_outer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Iframe Container 6 | 7 | 8 |
9 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/test_html/other_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Other Page 6 | 7 | 8 | iframe inner 9 | 10 |
11 | This page serves as a destination to be navigated to from another page. 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.codecov.com/docs/codecovyml-reference 2 | coverage: 3 | # Hold ourselves to a high bar 4 | range: 85..100 5 | round: down 6 | precision: 1 7 | status: 8 | # ref: https://docs.codecov.com/docs/commit-status 9 | project: 10 | default: 11 | # Avoid false negatives 12 | threshold: 1% 13 | 14 | # Test files aren't important for coverage 15 | ignore: 16 | - "tests" 17 | 18 | # Make comments less noisy 19 | comment: 20 | layout: "files" 21 | require_changes: true 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: cargo 8 | directory: / 9 | schedule: 10 | interval: daily 11 | ignore: 12 | - dependency-name: "*" 13 | # patch and minor updates don't matter for libraries as consumers of this library build 14 | # with their own lockfile, rather than the version specified in this library's lockfile 15 | # remove this ignore rule if your package has binaries to ensure that the binaries are 16 | # built with the exact set of dependencies and those are up to date. 17 | update-types: 18 | - "version-update:semver-patch" 19 | - "version-update:semver-minor" 20 | -------------------------------------------------------------------------------- /.github/DOCS.md: -------------------------------------------------------------------------------- 1 | # Github config and workflows 2 | 3 | In this folder there is configuration for codecoverage, dependabot, and ci 4 | workflows that check the library more deeply than the default configurations. 5 | 6 | This folder can be or was merged using a --allow-unrelated-histories merge 7 | strategy from which provides a 8 | reasonably sensible base for writing your own ci on. By using this strategy 9 | the history of the CI repo is included in your repo, and future updates to 10 | the CI can be merged later. 11 | 12 | To perform this merge run: 13 | 14 | ```shell 15 | git remote add ci https://github.com/jonhoo/rust-ci-conf.git 16 | git fetch ci 17 | git merge --allow-unrelated-histories ci/main 18 | ``` 19 | 20 | An overview of the files in this project is available at: 21 | , which contains some 22 | rationale for decisions and runs through an example of solving minimal version 23 | and OpenSSL issues. 24 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jon Gjengset 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/wait.rs: -------------------------------------------------------------------------------- 1 | use fantoccini::elements::Element; 2 | use fantoccini::{ClientBuilder, Locator}; 3 | use std::time::Duration; 4 | 5 | #[tokio::main] 6 | async fn main() -> Result<(), Box> { 7 | // Connect to webdriver instance that is listening on port 4444 8 | let client = ClientBuilder::native() 9 | .connect("http://localhost:4444") 10 | .await?; 11 | 12 | // Go to the Rust website. 13 | client.goto("https://www.rust-lang.org/").await?; 14 | 15 | // The explicit return types in following code is just to illustrate the type returned. 16 | // You can omit them in your code. 17 | 18 | // You can wait on anything that implements `WaitCondition` 19 | 20 | // Wait for a URL 21 | let _: () = client 22 | .wait() 23 | .for_url(&url::Url::parse("https://www.rust-lang.org/")?) 24 | .await?; 25 | 26 | // Wait for a locator, and get back the element. 27 | let _: Element = client 28 | .wait() 29 | .for_element(Locator::Css( 30 | r#"a.button-download[href="/learn/get-started"]"#, 31 | )) 32 | .await?; 33 | 34 | // By default it will time-out after 30 seconds and check every 250 milliseconds. 35 | // However, you can change this. 36 | 37 | let _: Element = client 38 | .wait() 39 | .at_most(Duration::from_secs(5)) 40 | .every(Duration::from_millis(100)) 41 | .for_element(Locator::Css( 42 | r#"a.button-download[href="/learn/get-started"]"#, 43 | )) 44 | .await?; 45 | 46 | // Then close the browser window. 47 | client.close().await?; 48 | 49 | // done 50 | Ok(()) 51 | } 52 | -------------------------------------------------------------------------------- /tests/remote.rs: -------------------------------------------------------------------------------- 1 | //! Tests that make use of external websites. 2 | 3 | use cookie::SameSite; 4 | use fantoccini::{error, Client}; 5 | use serial_test::serial; 6 | 7 | mod common; 8 | 9 | // Verifies that basic cookie handling works 10 | async fn handle_cookies_test(c: Client) -> Result<(), error::CmdError> { 11 | c.goto("https://www.wikipedia.org/").await?; 12 | 13 | let cookies = c.get_all_cookies().await?; 14 | assert!(!cookies.is_empty()); 15 | 16 | // Add a new cookie. 17 | use fantoccini::cookies::Cookie; 18 | let mut cookie = Cookie::new("cookietest", "fantoccini"); 19 | cookie.set_domain(".wikipedia.org"); 20 | cookie.set_path("/"); 21 | cookie.set_same_site(Some(SameSite::Lax)); 22 | c.add_cookie(cookie.clone()).await?; 23 | 24 | // Verify that the cookie exists. 25 | assert_eq!( 26 | c.get_named_cookie(cookie.name()).await?.value(), 27 | cookie.value() 28 | ); 29 | 30 | // Delete the cookie and make sure it's gone 31 | c.delete_cookie(cookie.name()).await?; 32 | assert!(c.get_named_cookie(cookie.name()).await.is_err()); 33 | 34 | // Verify same_site None corner-case is correctly parsed 35 | cookie.set_same_site(None); 36 | c.add_cookie(cookie.clone()).await?; 37 | assert_eq!( 38 | c.get_named_cookie(cookie.name()).await?.same_site(), 39 | Some(SameSite::None) 40 | ); 41 | 42 | c.delete_all_cookies().await?; 43 | let cookies = c.get_all_cookies().await?; 44 | assert!(dbg!(cookies).is_empty()); 45 | 46 | c.close().await 47 | } 48 | 49 | mod chrome { 50 | use super::*; 51 | 52 | #[test] 53 | #[serial] 54 | fn it_handles_cookies() { 55 | tester!(handle_cookies_test, "chrome"); 56 | } 57 | } 58 | 59 | mod firefox { 60 | use super::*; 61 | 62 | #[test] 63 | #[serial] 64 | fn it_handles_cookies() { 65 | tester!(handle_cookies_test, "firefox"); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "fantoccini" 3 | version = "0.22.0" 4 | edition = "2021" 5 | rust-version = "1.67.0" 6 | 7 | description = "High-level API for programmatically interacting with web pages through WebDriver." 8 | readme = "README.md" 9 | 10 | authors = ["Jon Gjengset "] 11 | 12 | documentation = "https://docs.rs/fantoccini" 13 | homepage = "https://github.com/jonhoo/fantoccini" 14 | repository = "https://github.com/jonhoo/fantoccini.git" 15 | 16 | keywords = ["webdriver", "chromedriver", "geckodriver", "phantomjs", "automation"] 17 | categories = ["api-bindings", "development-tools::testing", "web-programming::http-client"] 18 | 19 | license = "MIT OR Apache-2.0" 20 | 21 | [features] 22 | default = ["native-tls"] 23 | native-tls = ["hyper-tls", "openssl"] 24 | rustls-tls = ["hyper-rustls"] 25 | 26 | [dependencies] 27 | webdriver = { version = "0.53", default-features = false } 28 | url = "2.2.2" 29 | serde = { version = "1.0.103", features = ["derive"] } 30 | serde_json = "1.0.50" 31 | futures-util = { version = "0.3", default-features = false, features = ["alloc"] } 32 | tokio = { version = "1", features = ["sync", "rt", "time"] } 33 | hyper = { version = "1.1.0", features = ["client", "http1"] } 34 | hyper-util = { version = "0.1.3", features = ["client", "http1", "client-legacy", "tokio"] } 35 | http-body-util = { version = "0.1.0" } 36 | cookie = { version = "0.18.0", features = ["percent-encode"] } 37 | base64 = "0.22" 38 | hyper-rustls = { version = "0.27.0", optional = true } 39 | hyper-tls = { version = "0.6.0", optional = true } 40 | mime = "0.3.9" 41 | http = "1.0.0" 42 | time = "0.3" 43 | 44 | [dev-dependencies] 45 | tokio = { version = "1", features = ["full"] } 46 | hyper = { version = "1.1.0", features = ["server"] } 47 | hyper-util = { version = "0.1.3", features = ["server", "http1"] } 48 | serial_test = "3.0" 49 | 50 | # for minimal-versions 51 | [target.'cfg(any())'.dependencies] 52 | openssl = { version = "0.10.60", optional = true } # through native-tls, <.35 no longer builds 53 | openssl-macros = { version = "0.1.1", optional = true } 54 | 55 | [package.metadata.docs.rs] 56 | all-features = true 57 | rustdoc-args = ["--cfg", "docsrs"] 58 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | //! ## Setup 2 | //! 3 | //! This example assumes you have geckodriver or chromedriver listening at port 4444. 4 | //! 5 | //! You can start the webdriver instance by: 6 | //! 7 | //! ### geckodriver 8 | //! 9 | //! ```text 10 | //! geckodriver --port 4444 11 | //! ``` 12 | //! 13 | //! ### chromedriver 14 | //! 15 | //! ```text 16 | //! chromedriver --port=4444 17 | //! ``` 18 | //! 19 | //! ## To Run 20 | //! 21 | //! ``` 22 | //! cargo run --example basic 23 | //! ``` 24 | 25 | use fantoccini::{ClientBuilder, Locator}; 26 | use std::time::Duration; 27 | use tokio::time::sleep; 28 | 29 | #[tokio::main] 30 | async fn main() -> Result<(), Box> { 31 | // Connect to webdriver instance that is listening on port 4444 32 | let client = ClientBuilder::native() 33 | .connect("http://localhost:4444") 34 | .await?; 35 | 36 | // Go to the Rust website. 37 | client.goto("https://www.rust-lang.org/").await?; 38 | 39 | // Click the "Get Started" button. 40 | let button = client 41 | .wait() 42 | .for_element(Locator::Css( 43 | r#"a.button-download[href="/learn/get-started"]"#, 44 | )) 45 | .await?; 46 | button.click().await?; 47 | 48 | // Click the "Try Rust Without Installing" button (using XPath this time). 49 | let button = r#"//a[@class="button button-secondary" and @href="https://play.rust-lang.org/"]"#; 50 | let button = client.wait().for_element(Locator::XPath(button)).await?; 51 | button.click().await?; 52 | 53 | // Find the big textarea. 54 | let code_area = client 55 | .wait() 56 | .for_element(Locator::Css(".ace_text-input")) 57 | .await?; 58 | 59 | // And write in some code. 60 | code_area.send_keys("// Hello from Fantoccini\n").await?; 61 | 62 | // Now, let's run it! 63 | let button = r#"//button[.='Run']"#; 64 | let button = client.wait().for_element(Locator::XPath(button)).await?; 65 | button.click().await?; 66 | 67 | // Let the user marvel at what we achieved. 68 | sleep(Duration::from_millis(6000)).await; 69 | // Then close the browser window. 70 | client.close().await?; 71 | 72 | Ok(()) 73 | } 74 | -------------------------------------------------------------------------------- /.github/workflows/scheduled.yml: -------------------------------------------------------------------------------- 1 | # Run scheduled (rolling) jobs on a nightly basis, as your crate may break independently of any 2 | # given PR. E.g., updates to rust nightly and updates to this crates dependencies. See check.yml for 3 | # information about how the concurrency cancellation and workflow triggering works 4 | permissions: 5 | contents: read 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | schedule: 11 | - cron: '7 7 * * *' 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 14 | cancel-in-progress: true 15 | name: rolling 16 | jobs: 17 | # https://twitter.com/mycoliza/status/1571295690063753218 18 | nightly: 19 | runs-on: ubuntu-latest 20 | name: ubuntu / nightly 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | submodules: true 25 | - name: Install browsers 26 | run: | 27 | ./ci/ubuntu-latest-firefox 28 | ./ci/ubuntu-latest-chrome 29 | - name: Install nightly 30 | uses: dtolnay/rust-toolchain@nightly 31 | - name: cargo generate-lockfile 32 | if: hashFiles('Cargo.lock') == '' 33 | run: cargo generate-lockfile 34 | - name: cargo test --locked 35 | run: cargo test --locked --all-features --all-targets 36 | # https://twitter.com/alcuadrado/status/1571291687837732873 37 | update: 38 | # This action checks that updating the dependencies of this crate to the latest available that 39 | # satisfy the versions in Cargo.toml does not break this crate. This is important as consumers 40 | # of this crate will generally use the latest available crates. This is subject to the standard 41 | # Cargo semver rules (i.e cargo does not update to a new major version unless explicitly told 42 | # to). 43 | runs-on: ubuntu-latest 44 | name: ubuntu / beta / updated 45 | # There's no point running this if no Cargo.lock was checked in in the first place, since we'd 46 | # just redo what happened in the regular test job. Unfortunately, hashFiles only works in if on 47 | # steps, so we repeat it. 48 | steps: 49 | - uses: actions/checkout@v4 50 | with: 51 | submodules: true 52 | - name: Install browsers 53 | run: | 54 | ./ci/ubuntu-latest-firefox 55 | ./ci/ubuntu-latest-chrome 56 | - name: Install beta 57 | if: hashFiles('Cargo.lock') != '' 58 | uses: dtolnay/rust-toolchain@beta 59 | - name: cargo update 60 | if: hashFiles('Cargo.lock') != '' 61 | run: cargo update 62 | - name: cargo test 63 | if: hashFiles('Cargo.lock') != '' 64 | run: cargo test --locked --all-features --all-targets 65 | env: 66 | RUSTFLAGS: -D deprecated 67 | -------------------------------------------------------------------------------- /tests/alert.rs: -------------------------------------------------------------------------------- 1 | //! Alert tests 2 | use crate::common::sample_page_url; 3 | use fantoccini::{error, Client, Locator}; 4 | use serial_test::serial; 5 | 6 | mod common; 7 | 8 | async fn alert_accept(c: Client, port: u16) -> Result<(), error::CmdError> { 9 | let sample_url = sample_page_url(port); 10 | c.goto(&sample_url).await?; 11 | c.find(Locator::Id("button-alert")).await?.click().await?; 12 | assert_eq!(c.get_alert_text().await?, "This is an alert"); 13 | c.accept_alert().await?; 14 | assert!(matches!( 15 | c.get_alert_text().await, 16 | Err(e) if e.is_no_such_alert() 17 | )); 18 | 19 | c.find(Locator::Id("button-confirm")).await?.click().await?; 20 | assert_eq!(c.get_alert_text().await?, "Press OK or Cancel"); 21 | c.accept_alert().await?; 22 | assert!(matches!( 23 | c.get_alert_text().await, 24 | Err(e) if e.is_no_such_alert() 25 | )); 26 | assert_eq!( 27 | c.find(Locator::Id("alert-answer")).await?.text().await?, 28 | "OK" 29 | ); 30 | 31 | Ok(()) 32 | } 33 | 34 | async fn alert_dismiss(c: Client, port: u16) -> Result<(), error::CmdError> { 35 | let sample_url = sample_page_url(port); 36 | c.goto(&sample_url).await?; 37 | c.find(Locator::Id("button-alert")).await?.click().await?; 38 | assert_eq!(c.get_alert_text().await?, "This is an alert"); 39 | c.dismiss_alert().await?; 40 | assert!(matches!( 41 | c.get_alert_text().await, 42 | Err(e) if e.is_no_such_alert() 43 | )); 44 | 45 | c.find(Locator::Id("button-confirm")).await?.click().await?; 46 | assert_eq!(c.get_alert_text().await?, "Press OK or Cancel"); 47 | c.dismiss_alert().await?; 48 | assert!(matches!( 49 | c.get_alert_text().await, 50 | Err(e) if e.is_no_such_alert() 51 | )); 52 | assert_eq!( 53 | c.find(Locator::Id("alert-answer")).await?.text().await?, 54 | "Cancel" 55 | ); 56 | 57 | Ok(()) 58 | } 59 | 60 | async fn alert_text(c: Client, port: u16) -> Result<(), error::CmdError> { 61 | let sample_url = sample_page_url(port); 62 | c.goto(&sample_url).await?; 63 | c.find(Locator::Id("button-prompt")).await?.click().await?; 64 | assert_eq!(c.get_alert_text().await?, "What is your name?"); 65 | c.send_alert_text("Fantoccini").await?; 66 | c.accept_alert().await?; 67 | assert!(matches!( 68 | c.get_alert_text().await, 69 | Err(e) if e.is_no_such_alert() 70 | )); 71 | assert_eq!( 72 | c.find(Locator::Id("alert-answer")).await?.text().await?, 73 | "Fantoccini" 74 | ); 75 | 76 | Ok(()) 77 | } 78 | 79 | mod firefox { 80 | use super::*; 81 | 82 | #[test] 83 | #[serial] 84 | fn alert_accept_test() { 85 | local_tester!(alert_accept, "firefox"); 86 | } 87 | 88 | #[test] 89 | #[serial] 90 | fn alert_dismiss_test() { 91 | local_tester!(alert_dismiss, "firefox"); 92 | } 93 | 94 | #[test] 95 | #[serial] 96 | fn alert_text_test() { 97 | local_tester!(alert_text, "firefox"); 98 | } 99 | } 100 | 101 | mod chrome { 102 | use super::*; 103 | 104 | #[test] 105 | #[serial] 106 | fn alert_accept_test() { 107 | local_tester!(alert_accept, "chrome"); 108 | } 109 | 110 | #[test] 111 | #[serial] 112 | fn alert_dismiss_test() { 113 | local_tester!(alert_dismiss, "chrome"); 114 | } 115 | 116 | #[test] 117 | #[serial] 118 | fn alert_text_test() { 119 | local_tester!(alert_text, "chrome"); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs whenever a PR is opened or updated, or a commit is pushed to main. It runs 2 | # several checks: 3 | # - fmt: checks that the code is formatted according to rustfmt 4 | # - clippy: checks that the code does not contain any clippy warnings 5 | # - doc: checks that the code can be documented without errors 6 | # - hack: check combinations of feature flags 7 | # - msrv: check that the msrv specified in the crate is correct 8 | permissions: 9 | contents: read 10 | # This configuration allows maintainers of this repo to create a branch and pull request based on 11 | # the new branch. Restricting the push trigger to the main branch ensures that the PR only gets 12 | # built once. 13 | on: 14 | push: 15 | branches: [main] 16 | pull_request: 17 | # If new code is pushed to a PR branch, then cancel in progress workflows for that PR. Ensures that 18 | # we don't waste CI time, and returns results quicker https://github.com/jonhoo/rust-ci-conf/pull/5 19 | concurrency: 20 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 21 | cancel-in-progress: true 22 | name: check 23 | jobs: 24 | fmt: 25 | runs-on: ubuntu-latest 26 | name: stable / fmt 27 | steps: 28 | - uses: actions/checkout@v4 29 | with: 30 | submodules: true 31 | - name: Install stable 32 | uses: dtolnay/rust-toolchain@stable 33 | with: 34 | components: rustfmt 35 | - name: cargo fmt --check 36 | run: cargo fmt --check 37 | clippy: 38 | runs-on: ubuntu-latest 39 | name: ${{ matrix.toolchain }} / clippy 40 | permissions: 41 | contents: read 42 | checks: write 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | # Get early warning of new lints which are regularly introduced in beta channels. 47 | toolchain: [stable, beta] 48 | steps: 49 | - uses: actions/checkout@v4 50 | with: 51 | submodules: true 52 | - name: Install ${{ matrix.toolchain }} 53 | uses: dtolnay/rust-toolchain@master 54 | with: 55 | toolchain: ${{ matrix.toolchain }} 56 | components: clippy 57 | - name: cargo clippy 58 | uses: giraffate/clippy-action@v1 59 | with: 60 | reporter: 'github-pr-check' 61 | github_token: ${{ secrets.GITHUB_TOKEN }} 62 | semver: 63 | runs-on: ubuntu-latest 64 | name: semver 65 | steps: 66 | - uses: actions/checkout@v4 67 | with: 68 | submodules: true 69 | - name: Install stable 70 | uses: dtolnay/rust-toolchain@stable 71 | with: 72 | components: rustfmt 73 | - name: cargo-semver-checks 74 | uses: obi1kenobi/cargo-semver-checks-action@v2 75 | doc: 76 | # run docs generation on nightly rather than stable. This enables features like 77 | # https://doc.rust-lang.org/beta/unstable-book/language-features/doc-cfg.html which allows an 78 | # API be documented as only available in some specific platforms. 79 | runs-on: ubuntu-latest 80 | name: nightly / doc 81 | steps: 82 | - uses: actions/checkout@v4 83 | with: 84 | submodules: true 85 | - name: Install nightly 86 | uses: dtolnay/rust-toolchain@nightly 87 | - name: Install cargo-docs-rs 88 | uses: dtolnay/install@cargo-docs-rs 89 | - name: cargo docs-rs 90 | run: cargo docs-rs 91 | hack: 92 | # cargo-hack checks combinations of feature flags to ensure that features are all additive 93 | # which is required for feature unification 94 | runs-on: ubuntu-latest 95 | name: ubuntu / stable / features 96 | steps: 97 | - uses: actions/checkout@v4 98 | with: 99 | submodules: true 100 | - name: Install stable 101 | uses: dtolnay/rust-toolchain@stable 102 | - name: cargo install cargo-hack 103 | uses: taiki-e/install-action@cargo-hack 104 | # intentionally no target specifier; see https://github.com/jonhoo/rust-ci-conf/pull/4 105 | # --feature-powerset runs for every combination of features 106 | - name: cargo hack 107 | run: cargo hack --feature-powerset check 108 | msrv: 109 | # check that we can build using the minimal rust version that is specified by this crate 110 | runs-on: ubuntu-latest 111 | # we use a matrix here just because env can't be used in job names 112 | # https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability 113 | strategy: 114 | matrix: 115 | msrv: ["1.82.0"] # tokio 116 | name: ubuntu / ${{ matrix.msrv }} 117 | steps: 118 | - uses: actions/checkout@v4 119 | with: 120 | submodules: true 121 | - name: Install ${{ matrix.msrv }} 122 | uses: dtolnay/rust-toolchain@master 123 | with: 124 | toolchain: ${{ matrix.msrv }} 125 | - name: cargo +${{ matrix.msrv }} check 126 | run: cargo check 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fantoccini 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/fantoccini.svg)](https://crates.io/crates/fantoccini) 4 | [![Documentation](https://docs.rs/fantoccini/badge.svg)](https://docs.rs/fantoccini/) 5 | [![codecov](https://codecov.io/gh/jonhoo/fantoccini/graph/badge.svg?token=NteBJ0F7Ok)](https://codecov.io/gh/jonhoo/fantoccini) 6 | 7 | A high-level API for programmatically interacting with web pages through WebDriver. 8 | 9 | This crate uses the [WebDriver protocol] to drive a conforming (potentially headless) browser 10 | through relatively high-level operations such as "click this element", "submit this form", etc. 11 | 12 | Most interactions are driven by using [CSS selectors]. With most WebDriver-compatible browser 13 | being fairly recent, the more expressive levels of the CSS standard are also supported, giving 14 | fairly [powerful] [operators]. 15 | 16 | Forms are managed by first calling `Client::form`, and then using the methods on `Form` to 17 | manipulate the form's fields and eventually submitting it. 18 | 19 | For low-level access to the page, `Client::source` can be used to fetch the full page HTML 20 | source code, and `Client::raw_client_for` to build a raw HTTP request for a particular URL. 21 | 22 | ## Examples 23 | 24 | These examples all assume that you have a [WebDriver compatible] process running on port 4444. 25 | A quick way to get one is to run [`geckodriver`] at the command line. 26 | 27 | Let's start out clicking around on Wikipedia: 28 | 29 | ```rust 30 | use fantoccini::{ClientBuilder, Locator}; 31 | 32 | // let's set up the sequence of steps we want the browser to take 33 | #[tokio::main] 34 | async fn main() -> Result<(), fantoccini::error::CmdError> { 35 | let c = ClientBuilder::native().connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 36 | 37 | // first, go to the Wikipedia page for Foobar 38 | c.goto("https://en.wikipedia.org/wiki/Foobar").await?; 39 | let url = c.current_url().await?; 40 | assert_eq!(url.as_ref(), "https://en.wikipedia.org/wiki/Foobar"); 41 | 42 | // click "Foo (disambiguation)" 43 | c.find(Locator::Css(".mw-disambig")).await?.click().await?; 44 | 45 | // click "Foo Lake" 46 | c.find(Locator::LinkText("Foo Lake")).await?.click().await?; 47 | 48 | let url = c.current_url().await?; 49 | assert_eq!(url.as_ref(), "https://en.wikipedia.org/wiki/Foo_Lake"); 50 | 51 | c.close().await 52 | } 53 | ``` 54 | 55 | How did we get to the Foobar page in the first place? We did a search! 56 | Let's make the program do that for us instead: 57 | 58 | ```rust 59 | // -- snip wrapper code -- 60 | // go to the Wikipedia frontpage this time 61 | c.goto("https://www.wikipedia.org/").await?; 62 | // find the search form, fill it out, and submit it 63 | let f = c.form(Locator::Css("#search-form")).await?; 64 | f.set_by_name("search", "foobar").await? 65 | .submit().await?; 66 | 67 | // we should now have ended up in the right place 68 | let url = c.current_url().await?; 69 | assert_eq!(url.as_ref(), "https://en.wikipedia.org/wiki/Foobar"); 70 | 71 | // -- snip wrapper code -- 72 | ``` 73 | 74 | What if we want to download a raw file? Fantoccini has you covered: 75 | 76 | ```rust 77 | // -- snip wrapper code -- 78 | // go back to the frontpage 79 | c.goto("https://www.wikipedia.org/").await?; 80 | // find the source for the Wikipedia globe 81 | let img = c.find(Locator::Css("img.central-featured-logo")).await?; 82 | let src = img.attr("src").await?.expect("image should have a src"); 83 | // now build a raw HTTP client request (which also has all current cookies) 84 | let raw = img.client().raw_client_for(http::Method::GET, &src).await?; 85 | 86 | // we then read out the image bytes 87 | use futures_util::TryStreamExt; 88 | let pixels = raw 89 | .into_body() 90 | .try_fold(Vec::new(), |mut data, chunk| async move { 91 | data.extend_from_slice(&chunk); 92 | Ok(data) 93 | }) 94 | .await 95 | .map_err(fantoccini::error::CmdError::from)?; 96 | // and voilà, we now have the bytes for the Wikipedia logo! 97 | assert!(pixels.len() > 0); 98 | println!("Wikipedia logo is {}b", pixels.len()); 99 | 100 | // -- snip wrapper code -- 101 | ``` 102 | 103 | For more examples, take a look at the `examples/` directory. 104 | 105 | # Contributing to fantoccini 106 | 107 | The following information applies only to developers interested in contributing 108 | to this project. If you simply want to use it to automate web browsers you can 109 | skip this section. 110 | 111 | ## How to run tests 112 | 113 | The tests assume that you have [`chromedriver`] and [`geckodriver`] already running on your system. 114 | You can download them using the links above. Then run them from separate tabs in your terminal. 115 | They will stay running until terminated with Ctrl+C or until the terminal session is closed. 116 | 117 | Then run `cargo test` from this project directory. 118 | 119 | [WebDriver protocol]: https://www.w3.org/TR/webdriver/ 120 | [CSS selectors]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors 121 | [powerful]: https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes 122 | [operators]: https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors 123 | [WebDriver compatible]: https://github.com/Fyrd/caniuse/issues/2757#issuecomment-304529217 124 | [`geckodriver`]: https://github.com/mozilla/geckodriver 125 | [`chromedriver`]: https://chromedriver.chromium.org/downloads 126 | -------------------------------------------------------------------------------- /tests/test_html/sample_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample Page 6 | 12 | 13 | 14 |
15 | 16 | 20 |
21 | 22 |

23 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 24 | Integer quis sapien eget quam hendrerit porttitor. 25 | Vivamus bibendum maximus tortor sed vehicula. 26 | Vivamus sollicitudin arcu feugiat felis auctor, at tincidunt velit aliquam. 27 | Mauris ac orci tellus. 28 | In sagittis, turpis eu pulvinar semper, lacus lorem aliquam nibh, quis rutrum turpis nisi et risus. 29 | Nullam consequat turpis at turpis egestas, ac volutpat libero malesuada. Maecenas pretium luctus dui eu ornare. 30 | Duis cursus leo eget placerat placerat. 31 | Aenean mauris tortor, tempus vel sagittis in, pretium eget mauris. 32 | Cras quam lorem, aliquet a lectus eu, malesuada faucibus ex. 33 | Sed porttitor nunc enim, at interdum nunc ultrices et. 34 | Nulla mollis viverra arcu at pretium. 35 | Mauris et sem accumsan, auctor libero et, gravida nisi. 36 | Aliquam erat volutpat. 37 | Nunc eget bibendum ipsum. 38 | Etiam convallis congue felis id elementum. 39 |

40 |

41 | Suspendisse vitae ultricies magna. 42 | Curabitur sit amet tempus quam, et pulvinar enim. 43 | Cras aliquet malesuada felis vitae semper. 44 | Integer id est fermentum turpis pulvinar suscipit eget ut diam. 45 | Etiam nulla sapien, pulvinar nec purus fringilla, tincidunt sagittis nunc. 46 | Suspendisse potenti. 47 | Integer sem ex, mattis in mollis vel, posuere in justo. 48 | Etiam laoreet consectetur libero, sed sagittis ex finibus eget. 49 | Maecenas vitae odio in eros eleifend pharetra quis ac sapien. 50 |

51 |

52 | Proin sagittis elit leo, non bibendum eros scelerisque non. 53 | Praesent ultricies pulvinar iaculis. 54 | Mauris in urna eget nisl dapibus faucibus aliquam fringilla sapien. 55 | Sed lacinia, magna bibendum vulputate malesuada, est justo porttitor augue, a iaculis sapien lorem ut turpis. 56 | Nulla a ante fringilla mi blandit tincidunt. 57 | Nam venenatis sodales sagittis. 58 | Ut ac mauris vel risus laoreet euismod. 59 | Mauris venenatis purus non nunc maximus congue. 60 | Vivamus a augue vel lorem ornare ultricies. 61 |

62 | 63 | Span 64 | 65 |
66 | 67 |
68 |
69 |
70 |
71 | 76 | 82 |
83 |
84 | 89 |
90 |
91 | 109 |

110 | 111 | 112 | 113 |
114 |
115 |
116 | 120 | 121 | 125 | 126 | 130 |
131 |
132 | 133 | 134 |
135 |
136 | 137 | 138 |
139 | 140 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /tests/elements.rs: -------------------------------------------------------------------------------- 1 | //! Element tests 2 | use crate::common::sample_page_url; 3 | use fantoccini::key::Key; 4 | use fantoccini::{error, Client, Locator}; 5 | use serial_test::serial; 6 | 7 | mod common; 8 | 9 | async fn element_is(c: Client, port: u16) -> Result<(), error::CmdError> { 10 | let sample_url = sample_page_url(port); 11 | c.goto(&sample_url).await?; 12 | let elem = c.find(Locator::Id("checkbox-option-1")).await?; 13 | assert!(elem.is_enabled().await?); 14 | assert!(elem.is_displayed().await?); 15 | assert!(!elem.is_selected().await?); 16 | elem.click().await?; 17 | let elem = c.find(Locator::Id("checkbox-option-1")).await?; 18 | assert!(elem.is_selected().await?); 19 | 20 | assert!( 21 | !c.find(Locator::Id("checkbox-disabled")) 22 | .await? 23 | .is_enabled() 24 | .await? 25 | ); 26 | assert!( 27 | !c.find(Locator::Id("checkbox-hidden")) 28 | .await? 29 | .is_displayed() 30 | .await? 31 | ); 32 | Ok(()) 33 | } 34 | 35 | async fn element_attr(c: Client, port: u16) -> Result<(), error::CmdError> { 36 | let sample_url = sample_page_url(port); 37 | c.goto(&sample_url).await?; 38 | let elem = c.find(Locator::Id("checkbox-option-1")).await?; 39 | assert_eq!(elem.attr("id").await?.unwrap(), "checkbox-option-1"); 40 | assert!(elem.attr("invalid-attribute").await?.is_none()); 41 | Ok(()) 42 | } 43 | 44 | async fn element_prop(c: Client, port: u16) -> Result<(), error::CmdError> { 45 | let sample_url = sample_page_url(port); 46 | c.goto(&sample_url).await?; 47 | let elem = c.find(Locator::Id("checkbox-option-1")).await?; 48 | assert_eq!(elem.prop("id").await?.unwrap(), "checkbox-option-1"); 49 | assert_eq!(elem.prop("checked").await?.unwrap(), "false"); 50 | assert!(elem.attr("invalid-property").await?.is_none()); 51 | Ok(()) 52 | } 53 | 54 | async fn element_css_value(c: Client, port: u16) -> Result<(), error::CmdError> { 55 | let sample_url = sample_page_url(port); 56 | c.goto(&sample_url).await?; 57 | let elem = c.find(Locator::Id("checkbox-hidden")).await?; 58 | assert_eq!(elem.css_value("display").await?, "none"); 59 | assert_eq!(elem.css_value("invalid-css-value").await?, ""); 60 | Ok(()) 61 | } 62 | 63 | async fn element_tag_name(c: Client, port: u16) -> Result<(), error::CmdError> { 64 | let sample_url = sample_page_url(port); 65 | c.goto(&sample_url).await?; 66 | let elem = c.find(Locator::Id("checkbox-option-1")).await?; 67 | let tag_name = elem.tag_name().await?; 68 | assert!( 69 | tag_name.eq_ignore_ascii_case("input"), 70 | "{} != input", 71 | tag_name 72 | ); 73 | Ok(()) 74 | } 75 | 76 | async fn element_rect(c: Client, port: u16) -> Result<(), error::CmdError> { 77 | let sample_url = sample_page_url(port); 78 | c.goto(&sample_url).await?; 79 | let elem = c.find(Locator::Id("button-alert")).await?; 80 | let rect = elem.rectangle().await?; 81 | // Rather than try to verify the exact position and size of the element, 82 | // let's just verify that the returned values deserialized ok and 83 | // are within the expected range. 84 | assert!(rect.0 > 0.0); 85 | assert!(rect.0 < 100.0); 86 | assert!(rect.1 > 0.0); 87 | assert!(rect.1 < 1000.0); 88 | assert!(rect.2 > 0.0); 89 | assert!(rect.2 < 200.0); 90 | assert!(rect.3 > 0.0); 91 | assert!(rect.3 < 200.0); 92 | Ok(()) 93 | } 94 | 95 | async fn element_send_keys(c: Client, port: u16) -> Result<(), error::CmdError> { 96 | let sample_url = sample_page_url(port); 97 | c.goto(&sample_url).await?; 98 | let elem = c.find(Locator::Id("text-input")).await?; 99 | assert_eq!(elem.prop("value").await?.unwrap(), ""); 100 | elem.send_keys("fantoccini").await?; 101 | assert_eq!(elem.prop("value").await?.unwrap(), "fantoccini"); 102 | let select_all = if cfg!(target_os = "macos") { 103 | Key::Command + "a" 104 | } else { 105 | Key::Control + "a" 106 | }; 107 | let backspace = Key::Backspace.to_string(); 108 | elem.send_keys(&select_all).await?; 109 | elem.send_keys(&backspace).await?; 110 | assert_eq!(elem.prop("value").await?.unwrap(), ""); 111 | 112 | Ok(()) 113 | } 114 | 115 | mod firefox { 116 | use super::*; 117 | 118 | #[test] 119 | #[serial] 120 | fn element_is_test() { 121 | local_tester!(element_is, "firefox"); 122 | } 123 | 124 | #[test] 125 | #[serial] 126 | fn element_attr_test() { 127 | local_tester!(element_attr, "firefox"); 128 | } 129 | 130 | #[test] 131 | #[serial] 132 | fn element_prop_test() { 133 | local_tester!(element_prop, "firefox"); 134 | } 135 | 136 | #[test] 137 | #[serial] 138 | fn element_css_value_test() { 139 | local_tester!(element_css_value, "firefox"); 140 | } 141 | 142 | #[test] 143 | #[serial] 144 | fn element_tag_name_test() { 145 | local_tester!(element_tag_name, "firefox"); 146 | } 147 | 148 | #[test] 149 | #[serial] 150 | fn element_rect_test() { 151 | local_tester!(element_rect, "firefox"); 152 | } 153 | 154 | #[test] 155 | #[serial] 156 | fn element_send_keys_test() { 157 | local_tester!(element_send_keys, "firefox"); 158 | } 159 | } 160 | 161 | mod chrome { 162 | use super::*; 163 | 164 | #[test] 165 | #[serial] 166 | fn element_is_test() { 167 | local_tester!(element_is, "chrome"); 168 | } 169 | 170 | #[test] 171 | #[serial] 172 | fn element_attr_test() { 173 | local_tester!(element_attr, "chrome"); 174 | } 175 | 176 | #[test] 177 | #[serial] 178 | fn element_prop_test() { 179 | local_tester!(element_prop, "chrome"); 180 | } 181 | 182 | #[test] 183 | #[serial] 184 | fn element_css_value_test() { 185 | local_tester!(element_css_value, "chrome"); 186 | } 187 | 188 | #[test] 189 | #[serial] 190 | fn element_tag_name_test() { 191 | local_tester!(element_tag_name, "chrome"); 192 | } 193 | 194 | #[test] 195 | #[serial] 196 | fn element_rect_test() { 197 | local_tester!(element_rect, "chrome"); 198 | } 199 | 200 | #[test] 201 | #[serial] 202 | fn element_send_keys_test() { 203 | local_tester!(element_send_keys, "chrome"); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/wait.rs: -------------------------------------------------------------------------------- 1 | //! Allow to wait for conditions. 2 | //! 3 | //! Sometimes it is necessary to wait for a browser to achieve a certain state. For example, 4 | //! navigating to a page may be take bit of time. And the time may vary between different 5 | //! environments and test runs. Static delays can work around this issue, but also prolong the 6 | //! test runs unnecessarily. Longer delays have less flaky tests, but even more unnecessary wait 7 | //! time. 8 | //! 9 | //! To wait as optimal as possible, you can use asynchronous wait operations, which periodically 10 | //! check for the expected state, re-try if necessary, but also fail after a certain time and still 11 | //! allow you to fail the test. Allow for longer grace periods, and only spending the time waiting 12 | //! when necessary. 13 | //! 14 | //! # Basic usage 15 | //! 16 | //! By default all wait operations will time-out after 30 seconds and will re-check every 17 | //! 250 milliseconds. You can configure this using the [`Wait::at_most`] and [`Wait::every`] 18 | //! methods or use [`Wait::forever`] to wait indefinitely. 19 | //! 20 | //! Once configured, you can start waiting on some condition by using the `Wait::for_*` methods. 21 | //! For example: 22 | //! 23 | //! ```no_run 24 | //! # use fantoccini::{ClientBuilder, Locator}; 25 | //! # #[tokio::main] 26 | //! # async fn main() -> Result<(), fantoccini::error::CmdError> { 27 | //! # #[cfg(all(feature = "native-tls", not(feature = "rustls-tls")))] 28 | //! # let client = ClientBuilder::native().connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 29 | //! # #[cfg(feature = "rustls-tls")] 30 | //! # let client = ClientBuilder::rustls().expect("rustls initialization").connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 31 | //! # #[cfg(all(not(feature = "native-tls"), not(feature = "rustls-tls")))] 32 | //! # let client: fantoccini::Client = unreachable!("no tls provider available"); 33 | //! // -- snip wrapper code -- 34 | //! let button = client.wait().for_element(Locator::Css( 35 | //! r#"a.button-download[href="/learn/get-started"]"#, 36 | //! )).await?; 37 | //! // -- snip wrapper code -- 38 | //! # client.close().await 39 | //! # } 40 | //! ``` 41 | //! 42 | //! # Error handling 43 | //! 44 | //! When a wait operation times out, it will return a [`CmdError::WaitTimeout`]. When a wait 45 | //! condition check returns an error, the wait operation will be aborted, and the error returned. 46 | 47 | use crate::elements::Element; 48 | use crate::error::{CmdError, ErrorStatus}; 49 | use crate::wd::Locator; 50 | use crate::Client; 51 | use std::time::{Duration, Instant}; 52 | 53 | const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); 54 | const DEFAULT_PERIOD: Duration = Duration::from_millis(250); 55 | 56 | /// Used for setting up a wait operation on the client. 57 | #[derive(Debug)] 58 | pub struct Wait<'c> { 59 | client: &'c Client, 60 | timeout: Option, 61 | period: Duration, 62 | } 63 | 64 | macro_rules! wait_on { 65 | ($self:ident, $ready:expr) => {{ 66 | let start = Instant::now(); 67 | loop { 68 | match $self.timeout { 69 | Some(timeout) if start.elapsed() > timeout => break Err(CmdError::WaitTimeout), 70 | _ => {} 71 | } 72 | match $ready? { 73 | Some(result) => break Ok(result), 74 | None => { 75 | tokio::time::sleep($self.period).await; 76 | } 77 | }; 78 | } 79 | }}; 80 | } 81 | 82 | impl<'c> Wait<'c> { 83 | /// Create a new wait operation from a client. 84 | /// 85 | /// This only starts the process of building a new wait operation. Waiting, and checking, will 86 | /// only begin once one of the consuming methods has been called. 87 | /// 88 | /// ```no_run 89 | /// # use fantoccini::{ClientBuilder, Locator}; 90 | /// # #[tokio::main] 91 | /// # async fn main() -> Result<(), fantoccini::error::CmdError> { 92 | /// # #[cfg(all(feature = "native-tls", not(feature = "rustls-tls")))] 93 | /// # let client = ClientBuilder::native().connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 94 | /// # #[cfg(feature = "rustls-tls")] 95 | /// # let client = ClientBuilder::rustls().expect("rustls initialization").connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 96 | /// # #[cfg(all(not(feature = "native-tls"), not(feature = "rustls-tls")))] 97 | /// # let client: fantoccini::Client = unreachable!("no tls provider available"); 98 | /// // -- snip wrapper code -- 99 | /// let button = client.wait().for_element(Locator::Css( 100 | /// r#"a.button-download[href="/learn/get-started"]"#, 101 | /// )).await?; 102 | /// // -- snip wrapper code -- 103 | /// # client.close().await 104 | /// # } 105 | /// ``` 106 | pub fn new(client: &'c Client) -> Self { 107 | Self { 108 | client, 109 | timeout: Some(DEFAULT_TIMEOUT), 110 | period: DEFAULT_PERIOD, 111 | } 112 | } 113 | 114 | /// Set the timeout until the operation should wait. 115 | #[must_use] 116 | pub fn at_most(mut self, timeout: Duration) -> Self { 117 | self.timeout = Some(timeout); 118 | self 119 | } 120 | 121 | /// Wait forever. 122 | #[must_use] 123 | pub fn forever(mut self) -> Self { 124 | self.timeout = None; 125 | self 126 | } 127 | 128 | /// Sets the period to delay checks. 129 | #[must_use] 130 | pub fn every(mut self, period: Duration) -> Self { 131 | self.period = period; 132 | self 133 | } 134 | 135 | /// Wait until a particular element can be found. 136 | pub async fn for_element(self, search: Locator<'_>) -> Result { 137 | wait_on!(self, { 138 | match self.client.by(search.into_parameters()).await { 139 | Ok(element) => Ok(Some(element)), 140 | Err(CmdError::Standard(w)) if w.error == ErrorStatus::NoSuchElement => Ok(None), 141 | Err(err) => Err(err), 142 | } 143 | }) 144 | } 145 | 146 | /// Wait until a given URL is reached. 147 | pub async fn for_url(self, url: &url::Url) -> Result<(), CmdError> { 148 | wait_on!(self, { 149 | Ok::<_, CmdError>(if self.client.current_url().await? == *url { 150 | Some(()) 151 | } else { 152 | None 153 | }) 154 | }) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/key.rs: -------------------------------------------------------------------------------- 1 | //! Key codes for use with Actions. 2 | 3 | use std::fmt::{Display, Formatter}; 4 | use std::ops::{Add, Deref}; 5 | 6 | /// Key codes for use with Actions. 7 | #[derive(Debug, Clone, Copy)] 8 | pub enum Key { 9 | /// Null 10 | Null, 11 | /// Cancel 12 | Cancel, 13 | /// Help 14 | Help, 15 | /// Backspace key 16 | Backspace, 17 | /// Tab key 18 | Tab, 19 | /// Clear 20 | Clear, 21 | /// Return key 22 | Return, 23 | /// Enter key 24 | Enter, 25 | /// Shift key 26 | Shift, 27 | /// Control key 28 | Control, 29 | /// Alt key 30 | Alt, 31 | /// Pause key 32 | Pause, 33 | /// Escape key 34 | Escape, 35 | /// Space bar 36 | Space, 37 | /// Page Up key 38 | PageUp, 39 | /// Page Down key 40 | PageDown, 41 | /// End key 42 | End, 43 | /// Home key 44 | Home, 45 | /// Left arrow key 46 | Left, 47 | /// Up arrow key 48 | Up, 49 | /// Right arrow key 50 | Right, 51 | /// Down arrow key 52 | Down, 53 | /// Insert key 54 | Insert, 55 | /// Delete key 56 | Delete, 57 | /// Semicolon key 58 | Semicolon, 59 | /// Equals key 60 | Equals, 61 | /// Numpad 0 key 62 | NumPad0, 63 | /// Numpad 1 key 64 | NumPad1, 65 | /// Numpad 2 key 66 | NumPad2, 67 | /// Numpad 3 key 68 | NumPad3, 69 | /// Numpad 4 key 70 | NumPad4, 71 | /// Numpad 5 key 72 | NumPad5, 73 | /// Numpad 6 key 74 | NumPad6, 75 | /// Numpad 7 key 76 | NumPad7, 77 | /// Numpad 8 key 78 | NumPad8, 79 | /// Numpad 9 key 80 | NumPad9, 81 | /// Multiply key 82 | Multiply, 83 | /// Add key 84 | Add, 85 | /// Separator key 86 | Separator, 87 | /// Subtract key 88 | Subtract, 89 | /// Decimal key 90 | Decimal, 91 | /// Divide key 92 | Divide, 93 | /// F1 key 94 | F1, 95 | /// F2 key 96 | F2, 97 | /// F3 key 98 | F3, 99 | /// F4 key 100 | F4, 101 | /// F5 key 102 | F5, 103 | /// F6 key 104 | F6, 105 | /// F7 key 106 | F7, 107 | /// F8 key 108 | F8, 109 | /// F9 key 110 | F9, 111 | /// F10 key 112 | F10, 113 | /// F11 key 114 | F11, 115 | /// F12 key 116 | F12, 117 | /// Meta key 118 | Meta, 119 | /// Command key 120 | Command, 121 | } 122 | 123 | impl Deref for Key { 124 | type Target = str; 125 | 126 | fn deref(&self) -> &str { 127 | match self { 128 | Key::Null => "\u{e000}", 129 | Key::Cancel => "\u{e001}", 130 | Key::Help => "\u{e002}", 131 | Key::Backspace => "\u{e003}", 132 | Key::Tab => "\u{e004}", 133 | Key::Clear => "\u{e005}", 134 | Key::Return => "\u{e006}", 135 | Key::Enter => "\u{e007}", 136 | Key::Shift => "\u{e008}", 137 | Key::Control => "\u{e009}", 138 | Key::Alt => "\u{e00a}", 139 | Key::Pause => "\u{e00b}", 140 | Key::Escape => "\u{e00c}", 141 | Key::Space => "\u{e00d}", 142 | Key::PageUp => "\u{e00e}", 143 | Key::PageDown => "\u{e00f}", 144 | Key::End => "\u{e010}", 145 | Key::Home => "\u{e011}", 146 | Key::Left => "\u{e012}", 147 | Key::Up => "\u{e013}", 148 | Key::Right => "\u{e014}", 149 | Key::Down => "\u{e015}", 150 | Key::Insert => "\u{e016}", 151 | Key::Delete => "\u{e017}", 152 | Key::Semicolon => "\u{e018}", 153 | Key::Equals => "\u{e019}", 154 | Key::NumPad0 => "\u{e01a}", 155 | Key::NumPad1 => "\u{e01b}", 156 | Key::NumPad2 => "\u{e01c}", 157 | Key::NumPad3 => "\u{e01d}", 158 | Key::NumPad4 => "\u{e01e}", 159 | Key::NumPad5 => "\u{e01f}", 160 | Key::NumPad6 => "\u{e020}", 161 | Key::NumPad7 => "\u{e021}", 162 | Key::NumPad8 => "\u{e022}", 163 | Key::NumPad9 => "\u{e023}", 164 | Key::Multiply => "\u{e024}", 165 | Key::Add => "\u{e025}", 166 | Key::Separator => "\u{e026}", 167 | Key::Subtract => "\u{e027}", 168 | Key::Decimal => "\u{e028}", 169 | Key::Divide => "\u{e029}", 170 | Key::F1 => "\u{e031}", 171 | Key::F2 => "\u{e032}", 172 | Key::F3 => "\u{e033}", 173 | Key::F4 => "\u{e034}", 174 | Key::F5 => "\u{e035}", 175 | Key::F6 => "\u{e036}", 176 | Key::F7 => "\u{e037}", 177 | Key::F8 => "\u{e038}", 178 | Key::F9 => "\u{e039}", 179 | Key::F10 => "\u{e03a}", 180 | Key::F11 => "\u{e03b}", 181 | Key::F12 => "\u{e03c}", 182 | Key::Meta => "\u{e03d}", 183 | Key::Command => "\u{e03d}", 184 | } 185 | } 186 | } 187 | 188 | impl Display for Key { 189 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 190 | write!(f, "{}", char::from(*self)) 191 | } 192 | } 193 | 194 | impl From for char { 195 | fn from(k: Key) -> char { 196 | k.chars().next().unwrap() 197 | } 198 | } 199 | 200 | impl Add<&str> for Key { 201 | type Output = String; 202 | 203 | fn add(self, rhs: &str) -> Self::Output { 204 | String::new() + &self + rhs 205 | } 206 | } 207 | 208 | impl Add<&Key> for &str { 209 | type Output = String; 210 | 211 | fn add(self, rhs: &Key) -> Self::Output { 212 | String::new() + &self + rhs 213 | } 214 | } 215 | 216 | #[cfg(test)] 217 | mod tests { 218 | use super::*; 219 | 220 | #[test] 221 | fn test_keys() { 222 | assert_eq!(Key::Control + &Key::Shift, "\u{e009}\u{e008}".to_string()); 223 | } 224 | 225 | #[test] 226 | fn test_key_str() { 227 | assert_eq!(Key::Control + "a", "\u{e009}a".to_string()); 228 | assert_eq!("a" + &Key::Control, "a\u{e009}".to_string()); 229 | } 230 | 231 | #[test] 232 | fn test_key_string() { 233 | assert_eq!(Key::Control + &"a".to_string(), "\u{e009}a".to_string()); 234 | assert_eq!("a".to_string() + &Key::Control, "a\u{e009}".to_string()); 235 | } 236 | 237 | #[test] 238 | fn test_string_addassign() { 239 | let mut k = String::new(); 240 | k += &Key::Control; 241 | assert_eq!(k, "\u{e009}".to_string()); 242 | 243 | let mut k = "test".to_string(); 244 | k += &Key::Control; 245 | assert_eq!(k, "test\u{e009}".to_string()); 246 | } 247 | 248 | #[test] 249 | fn test_key_key_string() { 250 | assert_eq!( 251 | Key::Control + &Key::Alt + "e", 252 | "\u{e009}\u{e00a}e".to_string() 253 | ); 254 | } 255 | 256 | #[test] 257 | fn test_string_concatenation_not_broken() { 258 | let a = "a".to_string(); 259 | let b = "b".to_string(); 260 | let this_should_work = a + &b; 261 | assert_eq!(this_should_work, "ab"); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This is the main CI workflow that runs the test suite on all pushes to main and all pull requests. 2 | # It runs the following jobs: 3 | # - required: runs the test suite on ubuntu with stable and beta rust toolchains 4 | # - minimal: runs the test suite with the minimal versions of the dependencies that satisfy the 5 | # requirements of this crate, and its dependencies 6 | # - os-check: runs the test suite on mac and windows 7 | # - coverage: runs the test suite and collects coverage information 8 | # See check.yml for information about how the concurrency cancellation and workflow triggering works 9 | permissions: 10 | contents: read 11 | on: 12 | push: 13 | branches: [main] 14 | pull_request: 15 | concurrency: 16 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 17 | cancel-in-progress: true 18 | name: test 19 | jobs: 20 | required: 21 | runs-on: ${{ matrix.os }} 22 | name: ${{ matrix.os }} / ${{ matrix.browser }} / ${{ matrix.toolchain }} 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | os: [ubuntu-latest, macos-latest, windows-latest] 27 | browser: [firefox, chrome] 28 | toolchain: [stable] 29 | include: 30 | - os: ubuntu-latest 31 | browser: chrome 32 | toolchain: beta 33 | steps: 34 | - uses: actions/checkout@v4 35 | with: 36 | submodules: true 37 | - name: Install nasm for aws-lc 38 | if: runner.os == 'Windows' 39 | run: | 40 | choco install nasm 41 | echo "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 42 | - name: Install ${{ matrix.browser }} 43 | run: | 44 | ./ci/${{ matrix.os }}-${{ matrix.browser }} 45 | - name: Install ${{ matrix.toolchain }} 46 | uses: dtolnay/rust-toolchain@master 47 | with: 48 | toolchain: ${{ matrix.toolchain }} 49 | - name: cargo generate-lockfile 50 | # enable this ci template to run regardless of whether the lockfile is checked in or not 51 | if: hashFiles('Cargo.lock') == '' 52 | run: cargo generate-lockfile 53 | # https://twitter.com/jonhoo/status/1571290371124260865 54 | - name: cargo test --locked 55 | run: cargo test --locked --all-features --all-targets ${{ matrix.browser }} 56 | # https://github.com/rust-lang/cargo/issues/6669 57 | - name: cargo test --doc 58 | run: cargo test --locked --all-features --doc 59 | minimal: 60 | # This action chooses the oldest version of the dependencies permitted by Cargo.toml to ensure 61 | # that this crate is compatible with the minimal version that this crate and its dependencies 62 | # require. This will pickup issues where this create relies on functionality that was introduced 63 | # later than the actual version specified (e.g., when we choose just a major version, but a 64 | # method was added after this version). 65 | # 66 | # This particular check can be difficult to get to succeed as often transitive dependencies may 67 | # be incorrectly specified (e.g., a dependency specifies 1.0 but really requires 1.1.5). There 68 | # is an alternative flag available -Zdirect-minimal-versions that uses the minimal versions for 69 | # direct dependencies of this crate, while selecting the maximal versions for the transitive 70 | # dependencies. Alternatively, you can add a line in your Cargo.toml to artificially increase 71 | # the minimal dependency, which you do with e.g.: 72 | # ```toml 73 | # # for minimal-versions 74 | # [target.'cfg(any())'.dependencies] 75 | # openssl = { version = "0.10.55", optional = true } # needed to allow foo to build with -Zminimal-versions 76 | # ``` 77 | # The optional = true is necessary in case that dependency isn't otherwise transitively required 78 | # by your library, and the target bit is so that this dependency edge never actually affects 79 | # Cargo build order. See also 80 | # https://github.com/jonhoo/fantoccini/blob/fde336472b712bc7ebf5b4e772023a7ba71b2262/Cargo.toml#L47-L49. 81 | # This action is run on ubuntu with the stable toolchain, as it is not expected to fail 82 | runs-on: ubuntu-latest 83 | name: ubuntu / stable / minimal-versions 84 | steps: 85 | - uses: actions/checkout@v4 86 | with: 87 | submodules: true 88 | - name: Install browsers 89 | run: | 90 | ./ci/ubuntu-latest-firefox 91 | ./ci/ubuntu-latest-chrome 92 | - name: Install stable 93 | uses: dtolnay/rust-toolchain@stable 94 | - name: Install nightly for -Zminimal-versions 95 | uses: dtolnay/rust-toolchain@nightly 96 | - name: rustup default stable 97 | run: rustup default stable 98 | - name: cargo update -Zminimal-versions 99 | run: cargo +nightly update -Zminimal-versions 100 | - name: cargo test 101 | run: cargo test --locked --all-features --all-targets 102 | coverage: 103 | # use llvm-cov to build and collect coverage and outputs in a format that 104 | # is compatible with codecov.io 105 | # 106 | # note that codecov as of v4 requires that CODECOV_TOKEN from 107 | # 108 | # https://app.codecov.io/gh///settings 109 | # 110 | # is set in two places on your repo: 111 | # 112 | # - https://github.com/jonhoo/guardian/settings/secrets/actions 113 | # - https://github.com/jonhoo/guardian/settings/secrets/dependabot 114 | # 115 | # (the former is needed for codecov uploads to work with Dependabot PRs) 116 | # 117 | # PRs coming from forks of your repo will not have access to the token, but 118 | # for those, codecov allows uploading coverage reports without a token. 119 | # it's all a little weird and inconvenient. see 120 | # 121 | # https://github.com/codecov/feedback/issues/112 122 | # 123 | # for lots of more discussion 124 | runs-on: ubuntu-latest 125 | name: ubuntu / stable / coverage 126 | steps: 127 | - uses: actions/checkout@v4 128 | with: 129 | submodules: true 130 | - name: Install browsers 131 | run: | 132 | ./ci/ubuntu-latest-firefox 133 | ./ci/ubuntu-latest-chrome 134 | - name: Install stable 135 | uses: dtolnay/rust-toolchain@stable 136 | with: 137 | components: llvm-tools-preview 138 | - name: cargo install cargo-llvm-cov 139 | uses: taiki-e/install-action@cargo-llvm-cov 140 | - name: cargo generate-lockfile 141 | if: hashFiles('Cargo.lock') == '' 142 | run: cargo generate-lockfile 143 | - name: cargo llvm-cov 144 | run: cargo llvm-cov --locked --all-features --lcov --output-path lcov.info 145 | - name: Record Rust version 146 | run: echo "RUST=$(rustc --version)" >> "$GITHUB_ENV" 147 | - name: Upload to codecov.io 148 | uses: codecov/codecov-action@v5 149 | with: 150 | fail_ci_if_error: true 151 | token: ${{ secrets.CODECOV_TOKEN }} 152 | env_vars: OS,RUST 153 | -------------------------------------------------------------------------------- /src/cookies.rs: -------------------------------------------------------------------------------- 1 | //! Cookie-related functionality for WebDriver. 2 | 3 | use cookie::SameSite; 4 | use serde::{Deserialize, Serialize}; 5 | use std::convert::{TryFrom, TryInto}; 6 | use time::OffsetDateTime; 7 | use webdriver::command::{AddCookieParameters, WebDriverCommand}; 8 | use webdriver::common::Date; 9 | 10 | use crate::client::Client; 11 | use crate::error; 12 | 13 | /// Type alias for a [cookie::Cookie] 14 | pub type Cookie<'a> = cookie::Cookie<'a>; 15 | 16 | /// Wrapper for serializing AddCookieParameters. 17 | #[derive(Debug, Serialize)] 18 | pub(crate) struct AddCookieParametersWrapper<'a> { 19 | /// The cookie to serialize. 20 | #[serde(with = "AddCookieParameters")] 21 | pub(crate) cookie: &'a AddCookieParameters, 22 | } 23 | 24 | /// Representation of a cookie as [defined by WebDriver](https://www.w3.org/TR/webdriver1/#cookies). 25 | #[derive(Debug, Deserialize, Serialize)] 26 | pub(crate) struct WebDriverCookie { 27 | name: String, 28 | value: String, 29 | #[serde(skip_serializing_if = "Option::is_none")] 30 | path: Option, 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | domain: Option, 33 | #[serde(skip_serializing_if = "Option::is_none")] 34 | secure: Option, 35 | #[serde(skip_serializing_if = "Option::is_none", rename = "httpOnly")] 36 | http_only: Option, 37 | #[serde(skip_serializing_if = "Option::is_none")] 38 | expiry: Option, 39 | #[serde(skip_serializing_if = "Option::is_none", rename = "sameSite")] 40 | same_site: Option, 41 | } 42 | 43 | impl WebDriverCookie { 44 | fn into_params(self) -> AddCookieParameters { 45 | AddCookieParameters { 46 | name: self.name, 47 | value: self.value, 48 | path: self.path, 49 | domain: self.domain, 50 | secure: self.secure.unwrap_or_default(), 51 | httpOnly: self.http_only.unwrap_or_default(), 52 | expiry: self.expiry.map(Date), 53 | sameSite: self.same_site, 54 | } 55 | } 56 | } 57 | 58 | impl TryFrom for Cookie<'static> { 59 | type Error = error::CmdError; 60 | 61 | fn try_from(webdriver_cookie: WebDriverCookie) -> Result { 62 | let mut cookie = cookie::Cookie::new(webdriver_cookie.name, webdriver_cookie.value); 63 | 64 | if let Some(path) = webdriver_cookie.path { 65 | cookie.set_path(path); 66 | } 67 | 68 | if let Some(domain) = webdriver_cookie.domain { 69 | cookie.set_domain(domain); 70 | } 71 | 72 | if let Some(secure) = webdriver_cookie.secure { 73 | cookie.set_secure(secure); 74 | } 75 | 76 | if let Some(http_only) = webdriver_cookie.http_only { 77 | cookie.set_http_only(http_only); 78 | } 79 | 80 | if let Some(expiry) = webdriver_cookie.expiry { 81 | let dt = OffsetDateTime::from_unix_timestamp(expiry as i64).ok(); 82 | cookie.set_expires(dt); 83 | } 84 | 85 | if let Some(same_site) = webdriver_cookie.same_site { 86 | cookie.set_same_site(match &same_site { 87 | x if x.eq_ignore_ascii_case("strict") => SameSite::Strict, 88 | x if x.eq_ignore_ascii_case("lax") => SameSite::Lax, 89 | x if x.eq_ignore_ascii_case("none") => SameSite::None, 90 | _ => { 91 | return Err(error::CmdError::InvalidArgument( 92 | "same_site".to_string(), 93 | same_site, 94 | )) 95 | } 96 | }); 97 | } 98 | 99 | Ok(cookie) 100 | } 101 | } 102 | 103 | impl<'a> From> for WebDriverCookie { 104 | fn from(cookie: Cookie<'a>) -> Self { 105 | let name = cookie.name().to_string(); 106 | let value = cookie.value().to_string(); 107 | let path = cookie.path().map(String::from); 108 | let domain = cookie.domain().map(String::from); 109 | let secure = cookie.secure(); 110 | let http_only = cookie.http_only(); 111 | let expiry = cookie 112 | .expires() 113 | .and_then(|e| e.datetime().map(|dt| dt.unix_timestamp() as u64)); 114 | let same_site = Some(match cookie.same_site() { 115 | Some(x) => match x { 116 | SameSite::Strict => "Strict".to_string(), 117 | SameSite::Lax => "Lax".to_string(), 118 | SameSite::None => "None".to_string(), 119 | }, 120 | None => "None".to_string(), 121 | }); 122 | 123 | Self { 124 | name, 125 | value, 126 | path, 127 | domain, 128 | secure, 129 | http_only, 130 | expiry, 131 | same_site, 132 | } 133 | } 134 | } 135 | 136 | /// [Cookies](https://www.w3.org/TR/webdriver1/#cookies) 137 | impl Client { 138 | /// Get all cookies associated with the current document. 139 | /// 140 | /// See [16.1 Get All Cookies](https://www.w3.org/TR/webdriver1/#get-all-cookies) of the 141 | /// WebDriver standard. 142 | pub async fn get_all_cookies(&self) -> Result>, error::CmdError> { 143 | let resp = self.issue(WebDriverCommand::GetCookies).await?; 144 | 145 | let webdriver_cookies: Vec = serde_json::from_value(resp)?; 146 | webdriver_cookies 147 | .into_iter() 148 | .map(|raw_cookie| raw_cookie.try_into()) 149 | .collect() 150 | } 151 | 152 | /// Get a single named cookie associated with the current document. 153 | /// 154 | /// See [16.2 Get Named Cookie](https://www.w3.org/TR/webdriver1/#get-named-cookie) of the 155 | /// WebDriver standard. 156 | pub async fn get_named_cookie(&self, name: &str) -> Result, error::CmdError> { 157 | let resp = self 158 | .issue(WebDriverCommand::GetNamedCookie(name.to_string())) 159 | .await?; 160 | let webdriver_cookie: WebDriverCookie = serde_json::from_value(resp)?; 161 | webdriver_cookie.try_into() 162 | } 163 | 164 | /// Add the specified cookie. 165 | /// 166 | /// See [16.3 Add Cookie](https://www.w3.org/TR/webdriver1/#add-cookie) of the 167 | /// WebDriver standard. 168 | pub async fn add_cookie(&self, cookie: Cookie<'static>) -> Result<(), error::CmdError> { 169 | let webdriver_cookie: WebDriverCookie = cookie.into(); 170 | self.issue(WebDriverCommand::AddCookie(webdriver_cookie.into_params())) 171 | .await?; 172 | Ok(()) 173 | } 174 | 175 | /// Delete a single cookie from the current document. 176 | /// 177 | /// See [16.4 Delete Cookie](https://www.w3.org/TR/webdriver1/#delete-cookie) of the 178 | /// WebDriver standard. 179 | pub async fn delete_cookie(&self, name: &str) -> Result<(), error::CmdError> { 180 | self.issue(WebDriverCommand::DeleteCookie(name.to_string())) 181 | .await 182 | .map(|_| ()) 183 | } 184 | 185 | /// Delete all cookies from the current document. 186 | /// 187 | /// See [16.5 Delete All Cookies](https://www.w3.org/TR/webdriver1/#delete-all-cookies) of the 188 | /// WebDriver standard. 189 | pub async fn delete_all_cookies(&self) -> Result<(), error::CmdError> { 190 | self.issue(WebDriverCommand::DeleteCookies) 191 | .await 192 | .map(|_| ()) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /tests/actions.rs: -------------------------------------------------------------------------------- 1 | //! Actions tests 2 | use crate::common::sample_page_url; 3 | use fantoccini::actions::{ 4 | Actions, InputSource, KeyAction, KeyActions, MouseActions, NullActions, PointerAction, 5 | MOUSE_BUTTON_LEFT, 6 | }; 7 | use fantoccini::key::Key; 8 | use fantoccini::{error, Client, Locator}; 9 | use serial_test::serial; 10 | use std::time::{Duration, Instant}; 11 | 12 | mod common; 13 | 14 | async fn actions_null(c: Client, port: u16) -> Result<(), error::CmdError> { 15 | let sample_url = sample_page_url(port); 16 | c.goto(&sample_url).await?; 17 | let null_actions = NullActions::new("null".to_string()).pause(Duration::from_secs(1)); 18 | let now = Instant::now(); 19 | c.perform_actions(null_actions).await?; 20 | assert!(now.elapsed().as_secs_f64() >= 1.0); 21 | Ok(()) 22 | } 23 | 24 | async fn actions_key(c: Client, port: u16) -> Result<(), error::CmdError> { 25 | let sample_url = sample_page_url(port); 26 | c.goto(&sample_url).await?; 27 | 28 | // Test pause. 29 | let key_pause = KeyActions::new("key".to_string()).pause(Duration::from_secs(1)); 30 | let now = Instant::now(); 31 | c.perform_actions(key_pause).await?; 32 | assert!(now.elapsed().as_secs_f64() >= 1.0); 33 | 34 | // Test key down/up. 35 | let elem = c.find(Locator::Id("text-input")).await?; 36 | elem.send_keys("a").await?; 37 | assert_eq!(elem.prop("value").await?.unwrap(), "a"); 38 | 39 | let key_actions = KeyActions::new("key".to_string()) 40 | .then(KeyAction::Down { 41 | value: Key::Backspace.into(), 42 | }) 43 | .then(KeyAction::Up { 44 | value: Key::Backspace.into(), 45 | }); 46 | elem.click().await?; 47 | c.perform_actions(key_actions).await?; 48 | let elem = c.find(Locator::Id("text-input")).await?; 49 | assert_eq!(elem.prop("value").await?.unwrap(), ""); 50 | Ok(()) 51 | } 52 | 53 | async fn actions_mouse(c: Client, port: u16) -> Result<(), error::CmdError> { 54 | let sample_url = sample_page_url(port); 55 | c.goto(&sample_url).await?; 56 | 57 | // Test pause. 58 | let mouse_pause = MouseActions::new("mouse".to_string()).pause(Duration::from_secs(1)); 59 | let now = Instant::now(); 60 | c.perform_actions(mouse_pause).await?; 61 | assert!(now.elapsed().as_secs_f64() >= 1.0); 62 | 63 | let elem = c.find(Locator::Id("button-alert")).await?; 64 | 65 | // Test mouse down/up. 66 | let mouse_actions = MouseActions::new("mouse".to_string()) 67 | .then(PointerAction::MoveToElement { 68 | element: elem, 69 | duration: None, 70 | x: 0., 71 | y: 0., 72 | }) 73 | .then(PointerAction::Down { 74 | button: MOUSE_BUTTON_LEFT, 75 | }) 76 | .then(PointerAction::Up { 77 | button: MOUSE_BUTTON_LEFT, 78 | }); 79 | 80 | c.perform_actions(mouse_actions).await?; 81 | assert_eq!(c.get_alert_text().await?, "This is an alert"); 82 | c.dismiss_alert().await?; 83 | Ok(()) 84 | } 85 | 86 | async fn actions_mouse_move(c: Client, port: u16) -> Result<(), error::CmdError> { 87 | // Set window size to avoid moving the cursor out-of-bounds during actions. 88 | c.set_window_rect(0, 0, 800, 800).await?; 89 | 90 | let sample_url = sample_page_url(port); 91 | c.goto(&sample_url).await?; 92 | 93 | let elem = c.find(Locator::Id("button-alert")).await?; 94 | let rect = elem.rectangle().await?; 95 | let elem_center_x = rect.0 + (rect.2 / 2.0); 96 | let elem_center_y = rect.1 + (rect.3 / 2.0); 97 | 98 | // Test mouse MoveBy. 99 | let mouse_actions = MouseActions::new("mouse".to_string()) 100 | // Move to a position at a known offset from the button. 101 | .then(PointerAction::MoveTo { 102 | duration: None, 103 | x: 0., 104 | y: elem_center_y - 100., 105 | }) 106 | // Now move by relative offset so that the cursor is now over the button. 107 | .then(PointerAction::MoveBy { 108 | duration: None, 109 | x: elem_center_x, 110 | y: 100., 111 | }) 112 | // Press left mouse button down. 113 | .then(PointerAction::Down { 114 | button: MOUSE_BUTTON_LEFT, 115 | }) 116 | // Release left mouse button. 117 | .then(PointerAction::Up { 118 | button: MOUSE_BUTTON_LEFT, 119 | }); 120 | 121 | // Sanity check - ensure no alerts are displayed prior to actions. 122 | assert!(matches!( 123 | c.get_alert_text().await, 124 | Err(e) if e.is_no_such_alert() 125 | )); 126 | 127 | let actions = Actions::from(mouse_actions); 128 | c.perform_actions(actions).await?; 129 | assert_eq!(c.get_alert_text().await?, "This is an alert"); 130 | c.accept_alert().await?; 131 | 132 | Ok(()) 133 | } 134 | 135 | async fn actions_release(c: Client, port: u16) -> Result<(), error::CmdError> { 136 | let sample_url = sample_page_url(port); 137 | c.goto(&sample_url).await?; 138 | 139 | // Focus the input element. 140 | let elem = c.find(Locator::Id("text-input")).await?; 141 | elem.click().await?; 142 | 143 | // Add initial text. 144 | let elem = c.find(Locator::Id("text-input")).await?; 145 | assert_eq!(elem.prop("value").await?.unwrap(), ""); 146 | 147 | // Press CONTROL key down and hold it. 148 | let key_actions = KeyActions::new("key".to_string()).then(KeyAction::Down { 149 | value: Key::Control.into(), 150 | }); 151 | c.perform_actions(key_actions).await?; 152 | 153 | // Now release all actions. This should release the control key. 154 | c.release_actions().await?; 155 | 156 | // Now press the 'a' key again. 157 | // 158 | // If the Control key was not released, this would do `Ctrl+a` (i.e. select all) 159 | // but there is no text so it would do nothing. 160 | // 161 | // However if the Control key was released (as expected) 162 | // then this will type 'a' into the text element. 163 | let key_actions = KeyActions::new("key".to_string()).then(KeyAction::Down { value: 'a' }); 164 | c.perform_actions(key_actions).await?; 165 | assert_eq!(elem.prop("value").await?.unwrap(), "a"); 166 | Ok(()) 167 | } 168 | 169 | mod firefox { 170 | use super::*; 171 | 172 | #[test] 173 | #[serial] 174 | fn actions_null_test() { 175 | local_tester!(actions_null, "firefox"); 176 | } 177 | 178 | #[test] 179 | #[serial] 180 | fn actions_key_test() { 181 | local_tester!(actions_key, "firefox"); 182 | } 183 | 184 | #[test] 185 | #[serial] 186 | fn actions_mouse_test() { 187 | local_tester!(actions_mouse, "firefox"); 188 | } 189 | 190 | #[test] 191 | #[serial] 192 | fn actions_mouse_move_test() { 193 | local_tester!(actions_mouse_move, "firefox"); 194 | } 195 | 196 | #[test] 197 | #[serial] 198 | fn actions_release_test() { 199 | local_tester!(actions_release, "firefox"); 200 | } 201 | } 202 | 203 | mod chrome { 204 | use super::*; 205 | 206 | #[test] 207 | #[serial] 208 | fn actions_null_test() { 209 | local_tester!(actions_null, "chrome"); 210 | } 211 | 212 | #[test] 213 | #[serial] 214 | fn actions_key_test() { 215 | local_tester!(actions_key, "chrome"); 216 | } 217 | 218 | #[test] 219 | #[serial] 220 | fn actions_mouse_test() { 221 | local_tester!(actions_mouse, "chrome"); 222 | } 223 | 224 | #[test] 225 | #[serial] 226 | fn actions_mouse_move_test() { 227 | local_tester!(actions_mouse_move, "chrome"); 228 | } 229 | 230 | #[test] 231 | #[serial] 232 | fn actions_release_test() { 233 | local_tester!(actions_release, "chrome"); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /tests/common.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | extern crate fantoccini; 4 | extern crate futures_util; 5 | 6 | use fantoccini::{error, Client, ClientBuilder}; 7 | 8 | use hyper::body::Bytes; 9 | use hyper::service::service_fn; 10 | use hyper::{Request, Response, StatusCode}; 11 | use hyper_util::rt::{TokioExecutor, TokioIo}; 12 | use serde_json::map; 13 | use std::convert::Infallible; 14 | use std::future::Future; 15 | use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; 16 | use std::path::Path; 17 | use tokio::fs::read_to_string; 18 | 19 | const ASSETS_DIR: &str = "tests/test_html"; 20 | 21 | pub fn make_capabilities(s: &str) -> map::Map { 22 | match s { 23 | "firefox" => { 24 | let mut caps = serde_json::map::Map::new(); 25 | let opts = serde_json::json!({ "args": ["--headless"] }); 26 | caps.insert("moz:firefoxOptions".to_string(), opts); 27 | caps 28 | } 29 | "chrome" => { 30 | let mut caps = serde_json::map::Map::new(); 31 | let opts = serde_json::json!({ 32 | "args": [ 33 | "--headless", 34 | "--disable-gpu", 35 | "--disable-dev-shm-usage", 36 | ], 37 | }); 38 | caps.insert("goog:chromeOptions".to_string(), opts); 39 | caps 40 | } 41 | browser => unimplemented!("unsupported browser backend {}", browser), 42 | } 43 | } 44 | 45 | pub async fn make_client( 46 | url: &str, 47 | caps: map::Map, 48 | conn: &str, 49 | ) -> Result { 50 | match conn { 51 | #[cfg(feature = "rustls-tls")] 52 | "rustls" => { 53 | ClientBuilder::rustls() 54 | .unwrap() 55 | .capabilities(caps) 56 | .connect(url) 57 | .await 58 | } 59 | #[cfg(not(feature = "rustls-tls"))] 60 | "rustls" => { 61 | panic!("Asked to run the rustls test, but the rustls-tls feature is not enabled") 62 | } 63 | #[cfg(feature = "native-tls")] 64 | "native" => { 65 | ClientBuilder::native() 66 | .capabilities(caps) 67 | .connect(url) 68 | .await 69 | } 70 | #[cfg(not(feature = "native-tls"))] 71 | "native" => { 72 | panic!("Asked to run the native test, but the native-tls feature is not enabled") 73 | } 74 | other => unimplemented!("Unsupported connector type {}", other), 75 | } 76 | } 77 | 78 | pub fn make_url(s: &str) -> &'static str { 79 | match s { 80 | "firefox" => "http://localhost:4444", 81 | "chrome" => "http://localhost:9515", 82 | browser => unimplemented!("unsupported browser backend {}", browser), 83 | } 84 | } 85 | 86 | pub fn handle_test_error( 87 | res: Result, Box>, 88 | ) -> bool { 89 | match res { 90 | Ok(Ok(_)) => true, 91 | Ok(Err(e)) => { 92 | eprintln!("test future failed to resolve: {:?}", e); 93 | false 94 | } 95 | Err(e) => { 96 | if let Some(e) = e.downcast_ref::() { 97 | eprintln!("test future panicked: {:?}", e); 98 | } else if let Some(e) = e.downcast_ref::() { 99 | eprintln!("test future panicked: {:?}", e); 100 | } else { 101 | eprintln!("test future panicked; an assertion probably failed"); 102 | } 103 | false 104 | } 105 | } 106 | } 107 | 108 | #[macro_export] 109 | macro_rules! tester { 110 | ($f:ident, $endpoint:expr) => {{ 111 | use common::{make_capabilities, make_url}; 112 | let url = make_url($endpoint); 113 | let caps = make_capabilities($endpoint); 114 | #[cfg(feature = "rustls-tls")] 115 | tester_inner!($f, common::make_client(url, caps.clone(), "rustls")); 116 | #[cfg(feature = "native-tls")] 117 | tester_inner!($f, common::make_client(url, caps, "native")); 118 | }}; 119 | } 120 | 121 | #[macro_export] 122 | macro_rules! tester_inner { 123 | ($f:ident, $connector:expr) => {{ 124 | use std::sync::{Arc, Mutex}; 125 | use std::thread; 126 | 127 | let c = $connector; 128 | 129 | // we'll need the session_id from the thread 130 | // NOTE: even if it panics, so can't just return it 131 | let session_id = Arc::new(Mutex::new(None)); 132 | 133 | // run test in its own thread to catch panics 134 | let sid = session_id.clone(); 135 | let res = thread::spawn(move || { 136 | let rt = tokio::runtime::Builder::new_current_thread() 137 | .enable_all() 138 | .build() 139 | .unwrap(); 140 | let c = rt.block_on(c).expect("failed to construct test client"); 141 | *sid.lock().unwrap() = rt.block_on(c.session_id()).unwrap(); 142 | // make sure we close, even if an assertion fails 143 | let x = rt.block_on(async move { 144 | let r = tokio::spawn($f(c.clone())).await; 145 | let _ = c.close().await; 146 | r 147 | }); 148 | drop(rt); 149 | x.expect("test panicked") 150 | }) 151 | .join(); 152 | let success = common::handle_test_error(res); 153 | assert!(success); 154 | }}; 155 | } 156 | 157 | #[macro_export] 158 | macro_rules! local_tester { 159 | ($f:ident, $endpoint:expr) => {{ 160 | let port = common::setup_server(); 161 | let url = common::make_url($endpoint); 162 | let caps = common::make_capabilities($endpoint); 163 | let f = move |c: Client| async move { $f(c, port).await }; 164 | #[cfg(feature = "rustls-tls")] 165 | tester_inner!(f, common::make_client(url, caps.clone(), "rustls")); 166 | #[cfg(feature = "native-tls")] 167 | tester_inner!(f, common::make_client(url, caps, "native")) 168 | }}; 169 | } 170 | 171 | /// Sets up the server and returns the port it bound to. 172 | pub fn setup_server() -> u16 { 173 | let (tx, rx) = std::sync::mpsc::channel(); 174 | 175 | std::thread::spawn(move || { 176 | let rt = tokio::runtime::Builder::new_multi_thread() 177 | .worker_threads(1) 178 | .enable_all() 179 | .build() 180 | .unwrap(); 181 | let _ = rt.block_on(async { 182 | let (socket_addr, server) = start_server(); 183 | tx.send(socket_addr.port()) 184 | .expect("To be able to send port"); 185 | server.await.expect("To start the server") 186 | }); 187 | }); 188 | 189 | rx.recv().expect("To get the bound port.") 190 | } 191 | 192 | /// Configures and starts the server 193 | fn start_server() -> ( 194 | SocketAddr, 195 | impl Future> + 'static, 196 | ) { 197 | let socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); 198 | let bind = TcpListener::bind(&socket_addr).unwrap(); 199 | let addr = bind.local_addr().unwrap(); 200 | 201 | let server = async move { 202 | bind.set_nonblocking(true).unwrap(); 203 | let bind = tokio::net::TcpListener::from_std(bind).unwrap(); 204 | loop { 205 | let (conn, _) = bind.accept().await.unwrap(); 206 | tokio::spawn(async move { 207 | hyper_util::server::conn::auto::Builder::new(TokioExecutor::new()) 208 | .serve_connection(TokioIo::new(conn), service_fn(handle_file_request)) 209 | .await 210 | .unwrap() 211 | }); 212 | } 213 | }; 214 | 215 | (addr, server) 216 | } 217 | 218 | /// Tries to return the requested html file 219 | async fn handle_file_request( 220 | req: Request, 221 | ) -> Result>, Infallible> { 222 | let uri_path = req.uri().path().trim_matches(&['/', '\\'][..]); 223 | 224 | // tests only contain html files 225 | // needed because the content-type: text/html is returned 226 | if !uri_path.ends_with(".html") { 227 | return Ok(file_not_found()); 228 | } 229 | 230 | // this does not protect against a directory traversal attack 231 | // but in this case it's not a risk 232 | let asset_file = Path::new(ASSETS_DIR).join(uri_path); 233 | 234 | let ctn = match read_to_string(asset_file).await { 235 | Ok(ctn) => ctn, 236 | Err(_) => return Ok(file_not_found()), 237 | }; 238 | 239 | let res = Response::builder() 240 | .header("content-type", "text/html") 241 | .header("content-length", ctn.len()) 242 | .body(ctn.into()) 243 | .unwrap(); 244 | 245 | Ok(res) 246 | } 247 | 248 | /// Response returned when a file is not found or could not be read 249 | fn file_not_found() -> Response> { 250 | Response::builder() 251 | .status(StatusCode::NOT_FOUND) 252 | .body(http_body_util::Full::new(Bytes::new())) 253 | .unwrap() 254 | } 255 | 256 | pub fn sample_page_url(port: u16) -> String { 257 | format!("http://localhost:{}/sample_page.html", port) 258 | } 259 | 260 | pub fn other_page_url(port: u16) -> String { 261 | format!("http://localhost:{}/other_page.html", port) 262 | } 263 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/wd.rs: -------------------------------------------------------------------------------- 1 | //! WebDriver types and declarations. 2 | use crate::error; 3 | #[cfg(doc)] 4 | use crate::Client; 5 | use http::Method; 6 | use serde::{Deserialize, Serialize}; 7 | use std::borrow::Cow; 8 | use std::convert::TryFrom; 9 | use std::fmt; 10 | use std::fmt::Debug; 11 | use std::time::Duration; 12 | use url::{ParseError, Url}; 13 | use webdriver::command::TimeoutsParameters; 14 | 15 | /// A command that can be sent to the WebDriver. 16 | /// 17 | /// Anything that implements this command can be sent to [`Client::issue_cmd()`] in order 18 | /// to send custom commands to the WebDriver instance. 19 | pub trait WebDriverCompatibleCommand: Debug { 20 | /// The endpoint to send the request to. 21 | fn endpoint( 22 | &self, 23 | base_url: &url::Url, 24 | session_id: Option<&str>, 25 | ) -> Result; 26 | 27 | /// The HTTP request method to use, and the request body for the request. 28 | /// 29 | /// The `url` will be the one returned from the `endpoint()` method above. 30 | fn method_and_body(&self, request_url: &url::Url) -> (http::Method, Option); 31 | 32 | /// Return true if this command starts a new WebDriver session. 33 | fn is_new_session(&self) -> bool { 34 | false 35 | } 36 | 37 | /// Return true if this session should only support the legacy webdriver protocol. 38 | /// 39 | /// This only applies to the obsolete JSON Wire Protocol and should return `false` 40 | /// for all implementations that follow the W3C specification. 41 | /// 42 | /// See for more 43 | /// details about JSON Wire Protocol. 44 | fn is_legacy(&self) -> bool { 45 | false 46 | } 47 | } 48 | 49 | /// Blanket implementation for &T, for better ergonomics. 50 | impl WebDriverCompatibleCommand for &T 51 | where 52 | T: WebDriverCompatibleCommand, 53 | { 54 | fn endpoint(&self, base_url: &Url, session_id: Option<&str>) -> Result { 55 | T::endpoint(self, base_url, session_id) 56 | } 57 | 58 | fn method_and_body(&self, request_url: &Url) -> (Method, Option) { 59 | T::method_and_body(self, request_url) 60 | } 61 | 62 | fn is_new_session(&self) -> bool { 63 | T::is_new_session(self) 64 | } 65 | 66 | fn is_legacy(&self) -> bool { 67 | T::is_legacy(self) 68 | } 69 | } 70 | 71 | /// Blanket implementation for Box, for better ergonomics. 72 | impl WebDriverCompatibleCommand for Box 73 | where 74 | T: WebDriverCompatibleCommand, 75 | { 76 | fn endpoint(&self, base_url: &Url, session_id: Option<&str>) -> Result { 77 | T::endpoint(self, base_url, session_id) 78 | } 79 | 80 | fn method_and_body(&self, request_url: &Url) -> (Method, Option) { 81 | T::method_and_body(self, request_url) 82 | } 83 | 84 | fn is_new_session(&self) -> bool { 85 | T::is_new_session(self) 86 | } 87 | 88 | fn is_legacy(&self) -> bool { 89 | T::is_legacy(self) 90 | } 91 | } 92 | 93 | /// A [handle][1] to a browser window. 94 | /// 95 | /// Should be obtained it via [`Client::window()`] method (or similar). 96 | /// 97 | /// [1]: https://www.w3.org/TR/webdriver/#dfn-window-handles 98 | #[derive(Clone, Debug, Eq, PartialEq)] 99 | pub struct WindowHandle(String); 100 | 101 | impl From for String { 102 | fn from(w: WindowHandle) -> Self { 103 | w.0 104 | } 105 | } 106 | 107 | impl<'a> TryFrom> for WindowHandle { 108 | type Error = error::InvalidWindowHandle; 109 | 110 | /// Makes the given string a [`WindowHandle`]. 111 | /// 112 | /// Avoids allocation if possible. 113 | /// 114 | /// # Errors 115 | /// 116 | /// If the given string is [`"current"`][1]. 117 | /// 118 | /// [1]: https://www.w3.org/TR/webdriver/#dfn-window-handles 119 | fn try_from(s: Cow<'a, str>) -> Result { 120 | if s != "current" { 121 | Ok(Self(s.into_owned())) 122 | } else { 123 | Err(error::InvalidWindowHandle) 124 | } 125 | } 126 | } 127 | 128 | impl TryFrom for WindowHandle { 129 | type Error = error::InvalidWindowHandle; 130 | 131 | /// Makes the given [`String`] a [`WindowHandle`]. 132 | /// 133 | /// # Errors 134 | /// 135 | /// If the given [`String`] is [`"current"`][1]. 136 | /// 137 | /// [1]: https://www.w3.org/TR/webdriver/#dfn-window-handles 138 | fn try_from(s: String) -> Result { 139 | Self::try_from(Cow::Owned(s)) 140 | } 141 | } 142 | 143 | impl TryFrom<&str> for WindowHandle { 144 | type Error = error::InvalidWindowHandle; 145 | 146 | /// Makes the given string a [`WindowHandle`]. 147 | /// 148 | /// Allocates if succeeds. 149 | /// 150 | /// # Errors 151 | /// 152 | /// If the given string is [`"current"`][1]. 153 | /// 154 | /// [1]: https://www.w3.org/TR/webdriver/#dfn-window-handles 155 | fn try_from(s: &str) -> Result { 156 | Self::try_from(Cow::Borrowed(s)) 157 | } 158 | } 159 | 160 | /// A type of a new browser window. 161 | /// 162 | /// Returned by [`Client::new_window()`] method. 163 | /// 164 | /// [`Client::new_window()`]: crate::Client::new_window 165 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 166 | pub enum NewWindowType { 167 | /// Opened in a tab. 168 | Tab, 169 | 170 | /// Opened in a separate window. 171 | Window, 172 | } 173 | 174 | impl fmt::Display for NewWindowType { 175 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 176 | match self { 177 | Self::Tab => write!(f, "tab"), 178 | Self::Window => write!(f, "window"), 179 | } 180 | } 181 | } 182 | 183 | /// Dynamic set of [WebDriver capabilities][1]. 184 | /// 185 | /// [1]: https://www.w3.org/TR/webdriver/#dfn-capability 186 | pub type Capabilities = serde_json::Map; 187 | 188 | /// An element locator. 189 | /// 190 | /// See [the specification][1] for more details. 191 | /// 192 | /// [1]: https://www.w3.org/TR/webdriver1/#locator-strategies 193 | #[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Debug, Hash)] 194 | pub enum Locator<'a> { 195 | /// Find an element matching the given [CSS selector][1]. 196 | /// 197 | /// [1]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors 198 | Css(&'a str), 199 | 200 | /// Find an element using the given [`id`][1]. 201 | /// 202 | /// [1]: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id 203 | Id(&'a str), 204 | 205 | /// Find a link element with the given link text. 206 | /// 207 | /// The text matching is exact. 208 | LinkText(&'a str), 209 | 210 | /// Find an element using the given [XPath expression][1]. 211 | /// 212 | /// You can address pretty much any element this way, if you're willing to 213 | /// put in the time to find the right XPath. 214 | /// 215 | /// [1]: https://developer.mozilla.org/en-US/docs/Web/XPath 216 | XPath(&'a str), 217 | } 218 | 219 | impl<'a> Locator<'a> { 220 | pub(crate) fn into_parameters(self) -> webdriver::command::LocatorParameters { 221 | use webdriver::command::LocatorParameters; 222 | use webdriver::common::LocatorStrategy; 223 | 224 | match self { 225 | Locator::Css(s) => LocatorParameters { 226 | using: LocatorStrategy::CSSSelector, 227 | value: s.to_string(), 228 | }, 229 | Locator::Id(s) => LocatorParameters { 230 | using: LocatorStrategy::XPath, 231 | value: format!("//*[@id=\"{}\"]", s), 232 | }, 233 | Locator::XPath(s) => LocatorParameters { 234 | using: LocatorStrategy::XPath, 235 | value: s.to_string(), 236 | }, 237 | Locator::LinkText(s) => LocatorParameters { 238 | using: LocatorStrategy::LinkText, 239 | value: s.to_string(), 240 | }, 241 | } 242 | } 243 | } 244 | 245 | /// The WebDriver status as returned by [`Client::status()`]. 246 | /// 247 | /// See [8.3 Status](https://www.w3.org/TR/webdriver1/#status) of the WebDriver standard. 248 | #[derive(Debug, Clone, Serialize, Deserialize)] 249 | pub struct WebDriverStatus { 250 | /// True if the webdriver is ready to start a new session. 251 | /// 252 | /// NOTE: Geckodriver will return `false` if a session has already started, since it 253 | /// only supports a single session. 254 | pub ready: bool, 255 | /// The current status message. 256 | pub message: String, 257 | } 258 | 259 | /// Timeout configuration, for various timeout settings. 260 | /// 261 | /// Used by [`Client::get_timeouts()`] and [`Client::update_timeouts()`]. 262 | #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] 263 | pub struct TimeoutConfiguration { 264 | #[serde(skip_serializing_if = "Option::is_none")] 265 | script: Option, 266 | #[serde(rename = "pageLoad", skip_serializing_if = "Option::is_none")] 267 | page_load: Option, 268 | #[serde(skip_serializing_if = "Option::is_none")] 269 | implicit: Option, 270 | } 271 | 272 | impl Default for TimeoutConfiguration { 273 | fn default() -> Self { 274 | TimeoutConfiguration::new( 275 | Some(Duration::from_secs(60)), 276 | Some(Duration::from_secs(60)), 277 | Some(Duration::from_secs(0)), 278 | ) 279 | } 280 | } 281 | 282 | impl TimeoutConfiguration { 283 | /// Create new timeout configuration. 284 | /// 285 | /// The various settings are as follows: 286 | /// - script Determines when to interrupt a script that is being evaluated. 287 | /// Default is 60 seconds. 288 | /// - page_load Provides the timeout limit used to interrupt navigation of the browsing 289 | /// context. Default is 60 seconds. 290 | /// - implicit Gives the timeout of when to abort locating an element. Default is 0 seconds. 291 | /// 292 | /// NOTE: It is recommended to leave the `implicit` timeout at 0 seconds, because that makes 293 | /// it possible to check for the non-existence of an element without an implicit delay. 294 | /// Also see [`Client::wait()`] for element polling functionality. 295 | pub fn new( 296 | script: Option, 297 | page_load: Option, 298 | implicit: Option, 299 | ) -> Self { 300 | TimeoutConfiguration { 301 | script: script.map(|x| x.as_millis() as u64), 302 | page_load: page_load.map(|x| x.as_millis() as u64), 303 | implicit: implicit.map(|x| x.as_millis() as u64), 304 | } 305 | } 306 | 307 | /// Get the script timeout. 308 | pub fn script(&self) -> Option { 309 | self.script.map(Duration::from_millis) 310 | } 311 | 312 | /// Set the script timeout. 313 | pub fn set_script(&mut self, timeout: Option) { 314 | self.script = timeout.map(|x| x.as_millis() as u64); 315 | } 316 | 317 | /// Get the page load timeout. 318 | pub fn page_load(&self) -> Option { 319 | self.page_load.map(Duration::from_millis) 320 | } 321 | 322 | /// Set the page load timeout. 323 | pub fn set_page_load(&mut self, timeout: Option) { 324 | self.page_load = timeout.map(|x| x.as_millis() as u64); 325 | } 326 | 327 | /// Get the implicit wait timeout. 328 | pub fn implicit(&self) -> Option { 329 | self.implicit.map(Duration::from_millis) 330 | } 331 | 332 | /// Set the implicit wait timeout. 333 | pub fn set_implicit(&mut self, timeout: Option) { 334 | self.implicit = timeout.map(|x| x.as_millis() as u64); 335 | } 336 | } 337 | 338 | impl TimeoutConfiguration { 339 | pub(crate) fn into_params(self) -> TimeoutsParameters { 340 | TimeoutsParameters { 341 | script: self.script.map(Some), 342 | page_load: self.page_load, 343 | implicit: self.implicit, 344 | } 345 | } 346 | } 347 | 348 | /// The response obtained when opening the WebDriver session. 349 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 350 | #[non_exhaustive] 351 | pub struct NewSessionResponse { 352 | #[serde(rename = "sessionId")] 353 | session_id: String, 354 | capabilities: Option, 355 | } 356 | 357 | impl NewSessionResponse { 358 | /// Get the session id. 359 | pub fn session_id(&self) -> &str { 360 | &self.session_id 361 | } 362 | 363 | /// Get the remote end capabilities. 364 | pub fn capabilities(&self) -> Option<&Capabilities> { 365 | self.capabilities.as_ref() 366 | } 367 | 368 | pub(crate) fn from_wd(nsr: webdriver::response::NewSessionResponse) -> Self { 369 | NewSessionResponse { 370 | session_id: nsr.session_id, 371 | capabilities: nsr.capabilities.as_object().cloned(), 372 | } 373 | } 374 | } 375 | 376 | pub use crate::print::{ 377 | PrintConfiguration, PrintConfigurationBuilder, PrintMargins, PrintOrientation, PrintPageRange, 378 | PrintSize, 379 | }; 380 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! A medium-level API for programmatically interacting with web pages through WebDriver. 2 | //! 3 | //! This crate uses the [WebDriver protocol] to drive a conforming (potentially headless) browser 4 | //! through relatively operations such as "click this element", "submit this form", etc. 5 | //! 6 | //! Most interactions are driven by using [CSS selectors]. With most WebDriver-compatible browser 7 | //! being fairly recent, the more expressive levels of the CSS standard are also supported, giving 8 | //! fairly [powerful] [operators]. 9 | //! 10 | //! Forms are managed by first calling `Client::form`, and then using the methods on `Form` to 11 | //! manipulate the form's fields and eventually submitting it. 12 | //! 13 | //! For low-level access to the page, `Client::source` can be used to fetch the full page HTML 14 | //! source code, and `Client::raw_client_for` to build a raw HTTP request for a particular URL. 15 | //! 16 | //! # Feature flags 17 | //! 18 | //! The following feature flags exist for this crate. 19 | //! 20 | //! - `native-tls`: Enable [ergonomic https connection](ClientBuilder::native) using [`native-tls`](https://crates.io/crates/native-tls) (enabled by default). 21 | //! - `rustls-tls`: Enable [ergonomic https connection](ClientBuilder::rustls) using Rusttls. 22 | //! 23 | //! # Examples 24 | //! 25 | //! These examples all assume that you have a [WebDriver compatible] process running on port 4444. 26 | //! A quick way to get one is to run [`geckodriver`] at the command line. The code also has 27 | //! partial support for the legacy WebDriver protocol used by `chromedriver` and `ghostdriver`. 28 | //! 29 | //! Let's start out clicking around on Wikipedia: 30 | //! 31 | //! ```no_run 32 | //! use fantoccini::{ClientBuilder, Locator}; 33 | //! 34 | //! // let's set up the sequence of steps we want the browser to take 35 | //! #[tokio::main] 36 | //! async fn main() -> Result<(), fantoccini::error::CmdError> { 37 | //! // Connecting using "native" TLS (with feature `native-tls`; on by default) 38 | //! # #[cfg(all(feature = "native-tls", not(feature = "rustls-tls")))] 39 | //! let c = ClientBuilder::native().connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 40 | //! // Connecting using Rustls (with feature `rustls-tls`) 41 | //! # #[cfg(feature = "rustls-tls")] 42 | //! let c = ClientBuilder::rustls().expect("rustls initialization").connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 43 | //! # #[cfg(all(not(feature = "native-tls"), not(feature = "rustls-tls")))] 44 | //! # let c: fantoccini::Client = unreachable!("no tls provider available"); 45 | //! 46 | //! // first, go to the Wikipedia page for Foobar 47 | //! c.goto("https://en.wikipedia.org/wiki/Foobar").await?; 48 | //! let url = c.current_url().await?; 49 | //! assert_eq!(url.as_ref(), "https://en.wikipedia.org/wiki/Foobar"); 50 | //! 51 | //! // click "Foo (disambiguation)" 52 | //! c.find(Locator::Css(".mw-disambig")).await?.click().await?; 53 | //! 54 | //! // click "Foo Lake" 55 | //! c.find(Locator::LinkText("Foo Lake")).await?.click().await?; 56 | //! 57 | //! let url = c.current_url().await?; 58 | //! assert_eq!(url.as_ref(), "https://en.wikipedia.org/wiki/Foo_Lake"); 59 | //! 60 | //! c.close().await 61 | //! } 62 | //! ``` 63 | //! 64 | //! How did we get to the Foobar page in the first place? We did a search! 65 | //! Let's make the program do that for us instead: 66 | //! 67 | //! ```no_run 68 | //! # use fantoccini::{ClientBuilder, Locator}; 69 | //! # #[tokio::main] 70 | //! # async fn main() -> Result<(), fantoccini::error::CmdError> { 71 | //! # #[cfg(all(feature = "native-tls", not(feature = "rustls-tls")))] 72 | //! # let c = ClientBuilder::native().connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 73 | //! # #[cfg(feature = "rustls-tls")] 74 | //! # let c = ClientBuilder::rustls().expect("rustls initialization").connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 75 | //! # #[cfg(all(not(feature = "native-tls"), not(feature = "rustls-tls")))] 76 | //! # let c: fantoccini::Client = unreachable!("no tls provider available"); 77 | //! // -- snip wrapper code -- 78 | //! // go to the Wikipedia frontpage this time 79 | //! c.goto("https://www.wikipedia.org/").await?; 80 | //! // find the search form, fill it out, and submit it 81 | //! let f = c.form(Locator::Css("#search-form")).await?; 82 | //! f.set_by_name("search", "foobar").await? 83 | //! .submit().await?; 84 | //! 85 | //! // we should now have ended up in the right place 86 | //! let url = c.current_url().await?; 87 | //! assert_eq!(url.as_ref(), "https://en.wikipedia.org/wiki/Foobar"); 88 | //! 89 | //! // -- snip wrapper code -- 90 | //! # c.close().await 91 | //! # } 92 | //! ``` 93 | //! 94 | //! What if we want to download a raw file? Fantoccini has you covered: 95 | //! 96 | //! ```no_run 97 | //! # use fantoccini::{ClientBuilder, Locator}; 98 | //! # #[tokio::main] 99 | //! # async fn main() -> Result<(), fantoccini::error::CmdError> { 100 | //! # #[cfg(all(feature = "native-tls", not(feature = "rustls-tls")))] 101 | //! # let c = ClientBuilder::native().connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 102 | //! # #[cfg(feature = "rustls-tls")] 103 | //! # let c = ClientBuilder::rustls().expect("rustls initialization").connect("http://localhost:4444").await.expect("failed to connect to WebDriver"); 104 | //! # #[cfg(all(not(feature = "native-tls"), not(feature = "rustls-tls")))] 105 | //! # let c: fantoccini::Client = unreachable!("no tls provider available"); 106 | //! // -- snip wrapper code -- 107 | //! // go back to the frontpage 108 | //! c.goto("https://www.wikipedia.org/").await?; 109 | //! // find the source for the Wikipedia globe 110 | //! let img = c.find(Locator::Css("img.central-featured-logo")).await?; 111 | //! let src = img.attr("src").await?.expect("image should have a src"); 112 | //! // now build a raw HTTP client request (which also has all current cookies) 113 | //! let raw = img.client().raw_client_for(hyper::Method::GET, &src).await?; 114 | //! 115 | //! // we then read out the image bytes 116 | //! use futures_util::TryStreamExt; 117 | //! use http_body_util::BodyExt; 118 | //! let pixels = raw 119 | //! .into_body() 120 | //! .collect() 121 | //! .await 122 | //! .map_err(fantoccini::error::CmdError::from)? 123 | //! .to_bytes(); 124 | //! // and voilla, we now have the bytes for the Wikipedia logo! 125 | //! assert!(pixels.len() > 0); 126 | //! println!("Wikipedia logo is {}b", pixels.len()); 127 | //! 128 | //! // -- snip wrapper code -- 129 | //! # c.close().await 130 | //! # } 131 | //! ``` 132 | //! 133 | //! For more examples, take a look at the `examples/` directory. 134 | //! 135 | //! [WebDriver protocol]: https://www.w3.org/TR/webdriver/ 136 | //! [CSS selectors]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors 137 | //! [powerful]: https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-classes 138 | //! [operators]: https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors 139 | //! [WebDriver compatible]: https://github.com/Fyrd/caniuse/issues/2757#issuecomment-304529217 140 | //! [`geckodriver`]: https://github.com/mozilla/geckodriver 141 | #![deny(missing_docs)] 142 | #![warn(missing_debug_implementations, rust_2018_idioms, rustdoc::all)] 143 | #![allow(rustdoc::missing_doc_code_examples, rustdoc::private_doc_tests)] 144 | #![cfg_attr(docsrs, feature(doc_cfg))] 145 | 146 | use crate::wd::Capabilities; 147 | use hyper_util::client::legacy::connect; 148 | 149 | macro_rules! via_json { 150 | ($x:expr) => {{ 151 | serde_json::from_str(&serde_json::to_string($x).unwrap()).unwrap() 152 | }}; 153 | } 154 | 155 | /// Error types. 156 | pub mod error; 157 | 158 | /// The long-running session future we spawn for multiplexing onto a running WebDriver instance. 159 | mod session; 160 | 161 | /// A [builder] for WebDriver [`Client`] instances. 162 | /// 163 | /// You will likely want to use [`native`](ClientBuilder::native) or 164 | /// [`rustls`](ClientBuilder::rustls) (depending on your preference) to start the builder. If you 165 | /// want to supply your own connector, use [`new`](ClientBuilder::new). 166 | /// 167 | /// Note: [`geckodriver`](https://github.com/mozilla/geckodriver) does not support multiple 168 | /// simultaneous instances. When using geckodriver, ensure you: 169 | /// 170 | /// - Run only one webdriver instance at a time 171 | /// - Explicitly close the webdriver session after use, even if an error occurs 172 | /// 173 | /// To run multiple webdriver instances use 174 | /// [chromedriver](https://developer.chrome.com/docs/chromedriver/downloads). 175 | /// 176 | /// To connect to the WebDriver instance, call [`connect`](ClientBuilder::connect). 177 | /// 178 | /// [builder]: https://rust-lang.github.io/api-guidelines/type-safety.html#c-builder 179 | #[derive(Default, Clone, Debug)] 180 | pub struct ClientBuilder 181 | where 182 | C: connect::Connect + Send + Sync + Clone + Unpin, 183 | { 184 | capabilities: Option, 185 | connector: C, 186 | } 187 | 188 | #[cfg(feature = "rustls-tls")] 189 | #[cfg_attr(docsrs, doc(cfg(feature = "rustls-tls")))] 190 | impl ClientBuilder> { 191 | /// Build a [`Client`] that will connect using [Rustls](https://crates.io/crates/rustls). 192 | pub fn rustls() -> std::io::Result { 193 | Ok(Self::new( 194 | hyper_rustls::HttpsConnectorBuilder::new() 195 | .with_native_roots()? 196 | .https_or_http() 197 | .enable_http1() 198 | .build(), 199 | )) 200 | } 201 | } 202 | 203 | #[cfg(feature = "native-tls")] 204 | #[cfg_attr(docsrs, doc(cfg(feature = "native-tls")))] 205 | impl ClientBuilder> { 206 | /// Build a [`Client`] that will connect using [`native-tls`](https://crates.io/crates/native-tls). 207 | pub fn native() -> Self { 208 | Self::new(hyper_tls::HttpsConnector::new()) 209 | } 210 | } 211 | impl ClientBuilder 212 | where 213 | C: connect::Connect + Send + Sync + Clone + Unpin + 'static, 214 | { 215 | /// Build a [`Client`] that will connect using the given HTTP `connector`. 216 | pub fn new(connector: C) -> Self { 217 | Self { 218 | capabilities: None, 219 | connector, 220 | } 221 | } 222 | 223 | /// Pass the given [WebDriver capabilities][1] to the browser. 224 | /// 225 | /// The WebDriver specification has a list of [standard 226 | /// capabilities](https://www.w3.org/TR/webdriver1/#capabilities), which are given below. In 227 | /// addition, most browser vendors support a number of browser-specific capabilities stored 228 | /// in an object under a prefixed key like 229 | /// [`moz:firefoxOptions`](https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions) 230 | /// or 231 | /// [`goog:chromeOptions`](https://developer.chrome.com/docs/chromedriver/capabilities). 232 | /// 233 | /// The standard options are given below. See the 234 | /// [specification](https://www.w3.org/TR/webdriver1/#capabilities) for more details. 235 | /// 236 | /// | Capability | Key | Value Type | Description | 237 | /// |------------|-----|------------|-------------| 238 | /// | Browser name | `"browserName"` | string | Identifies the user agent. | 239 | /// | Browser version | `"browserVersion"` | string | Identifies the version of the user agent. | 240 | /// | Platform name | `"platformName"` | string | Identifies the operating system of the endpoint node. | 241 | /// | Accept insecure TLS certificates | `"acceptInsecureCerts"` | boolean | Indicates whether untrusted and self-signed TLS certificates are implicitly trusted on navigation for the duration of the session. | 242 | /// | Page load strategy | `"pageLoadStrategy"` | string | Defines the current session’s page load strategy. | 243 | /// | Proxy configuration | `"proxy"` | JSON Object | Defines the current session’s proxy configuration. | 244 | /// | Window dimensioning/positioning | `"setWindowRect"` | boolean | Indicates whether the remote end supports all of the commands in Resizing and Positioning Windows. | 245 | /// | Session timeouts configuration | `"timeouts"` | JSON Object | Describes the timeouts imposed on certain session operations. | 246 | /// | Unhandled prompt behavior | `"unhandledPromptBehavior"` | string | Describes the current session’s user prompt handler. | 247 | /// 248 | /// [1]: https://www.w3.org/TR/webdriver/#dfn-capability 249 | pub fn capabilities(&mut self, cap: Capabilities) -> &mut Self { 250 | self.capabilities = Some(cap); 251 | self 252 | } 253 | 254 | /// Connect to the WebDriver session at the `webdriver` URL. 255 | pub async fn connect(&self, webdriver: &str) -> Result { 256 | if let Some(ref cap) = self.capabilities { 257 | Client::with_capabilities_and_connector(webdriver, cap, self.connector.clone()).await 258 | } else { 259 | Client::new_with_connector(webdriver, self.connector.clone()).await 260 | } 261 | } 262 | } 263 | 264 | pub mod client; 265 | #[doc(inline)] 266 | pub use client::Client; 267 | 268 | pub mod actions; 269 | pub mod cookies; 270 | pub mod elements; 271 | pub mod key; 272 | 273 | pub mod wait; 274 | 275 | pub mod wd; 276 | #[doc(inline)] 277 | pub use wd::Locator; 278 | 279 | mod print; 280 | -------------------------------------------------------------------------------- /src/print.rs: -------------------------------------------------------------------------------- 1 | use std::ops::RangeInclusive; 2 | 3 | use webdriver::command::PrintParameters; 4 | 5 | use crate::error::PrintConfigurationError; 6 | 7 | /// The builder of [`PrintConfiguration`]. 8 | #[derive(Debug)] 9 | pub struct PrintConfigurationBuilder { 10 | orientation: PrintOrientation, 11 | scale: f64, 12 | background: bool, 13 | size: PrintSize, 14 | margins: PrintMargins, 15 | page_ranges: Vec, 16 | shrink_to_fit: bool, 17 | } 18 | 19 | impl Default for PrintConfigurationBuilder { 20 | fn default() -> Self { 21 | Self { 22 | orientation: PrintOrientation::default(), 23 | scale: 1.0, 24 | background: false, 25 | size: PrintSize::default(), 26 | margins: PrintMargins::default(), 27 | page_ranges: Vec::default(), 28 | shrink_to_fit: true, 29 | } 30 | } 31 | } 32 | 33 | impl PrintConfigurationBuilder { 34 | /// Builds the [`PrintConfiguration`]. 35 | pub fn build(self) -> Result { 36 | let must_be_finite_and_positive = [ 37 | self.scale, 38 | self.margins.top, 39 | self.margins.left, 40 | self.margins.right, 41 | self.margins.bottom, 42 | self.size.width, 43 | self.size.height, 44 | ]; 45 | if !must_be_finite_and_positive 46 | .into_iter() 47 | .all(|n| n.is_finite()) 48 | { 49 | return Err(PrintConfigurationError::NonFiniteDimensions); 50 | } 51 | if !must_be_finite_and_positive 52 | .into_iter() 53 | .all(|n| n.is_sign_positive()) 54 | { 55 | return Err(PrintConfigurationError::NegativeDimensions); 56 | } 57 | 58 | if self.size.height < PrintSize::MIN.height || self.size.width < PrintSize::MIN.width { 59 | return Err(PrintConfigurationError::PrintSizeTooSmall); 60 | } 61 | 62 | if (self.margins.top + self.margins.bottom) >= self.size.height 63 | || (self.margins.left + self.margins.right) >= self.size.width 64 | { 65 | return Err(PrintConfigurationError::DimensionsOverflow); 66 | } 67 | 68 | Ok(PrintConfiguration { 69 | orientation: self.orientation, 70 | scale: self.scale, 71 | background: self.background, 72 | size: self.size, 73 | margins: self.margins, 74 | page_ranges: self.page_ranges, 75 | shrink_to_fit: self.shrink_to_fit, 76 | }) 77 | } 78 | 79 | /// Sets the orientation of the printed page. 80 | /// 81 | /// Default: [`PrintOrientation::Portrait`]. 82 | pub fn orientation(mut self, orientation: PrintOrientation) -> Self { 83 | self.orientation = orientation; 84 | 85 | self 86 | } 87 | 88 | /// Sets the scale of the printed page. 89 | /// 90 | /// Default: 1. 91 | pub fn scale(mut self, scale: f64) -> Self { 92 | self.scale = scale; 93 | 94 | self 95 | } 96 | 97 | /// Sets whether or not to print the backgrounds of the page. 98 | /// 99 | /// Default: false. 100 | pub fn background(mut self, background: bool) -> Self { 101 | self.background = background; 102 | 103 | self 104 | } 105 | 106 | /// Sets the size of the printed page. 107 | /// 108 | /// Default: [`PrintSize::A4`]. 109 | pub fn size(mut self, size: PrintSize) -> Self { 110 | self.size = size; 111 | 112 | self 113 | } 114 | 115 | /// Sets the margins of the printed page. 116 | /// 117 | /// Default: `1x1x1x1cm`. 118 | pub fn margins(mut self, margins: PrintMargins) -> Self { 119 | self.margins = margins; 120 | 121 | self 122 | } 123 | 124 | /// Sets ranges of pages to print. 125 | /// 126 | /// An empty `ranges` prints all pages, which is the default. 127 | pub fn page_ranges(mut self, ranges: Vec) -> Self { 128 | self.page_ranges = ranges; 129 | 130 | self 131 | } 132 | 133 | /// Sets whether or not to resize the content to fit the page width, 134 | /// overriding any page width specified in the content of pages to print. 135 | /// 136 | /// Default: true. 137 | pub fn shrink_to_fit(mut self, shrink_to_fit: bool) -> Self { 138 | self.shrink_to_fit = shrink_to_fit; 139 | 140 | self 141 | } 142 | } 143 | 144 | /// The print configuration. 145 | #[derive(Debug, Clone, PartialEq)] 146 | pub struct PrintConfiguration { 147 | orientation: PrintOrientation, 148 | scale: f64, 149 | background: bool, 150 | size: PrintSize, 151 | margins: PrintMargins, 152 | page_ranges: Vec, 153 | shrink_to_fit: bool, 154 | } 155 | 156 | impl PrintConfiguration { 157 | /// Creates a [`PrintConfigurationBuilder`] to configure a [`PrintConfiguration`]. 158 | pub fn builder() -> PrintConfigurationBuilder { 159 | PrintConfigurationBuilder::default() 160 | } 161 | 162 | pub(crate) fn into_params(self) -> PrintParameters { 163 | PrintParameters { 164 | orientation: self.orientation.into_params(), 165 | scale: self.scale, 166 | background: self.background, 167 | page: self.size.into_params(), 168 | margin: self.margins.into_params(), 169 | page_ranges: self 170 | .page_ranges 171 | .into_iter() 172 | .map(|page_range| page_range.into_params()) 173 | .collect(), 174 | shrink_to_fit: self.shrink_to_fit, 175 | } 176 | } 177 | } 178 | 179 | impl Default for PrintConfiguration { 180 | fn default() -> Self { 181 | PrintConfigurationBuilder::default() 182 | .build() 183 | .expect("default configuration is buildable") 184 | } 185 | } 186 | 187 | /// The orientation of the print. 188 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 189 | pub enum PrintOrientation { 190 | /// Landscape orientation. 191 | Landscape, 192 | #[default] 193 | /// Portrait orientation. 194 | Portrait, 195 | } 196 | 197 | impl PrintOrientation { 198 | pub(crate) fn into_params(self) -> webdriver::command::PrintOrientation { 199 | match self { 200 | Self::Landscape => webdriver::command::PrintOrientation::Landscape, 201 | Self::Portrait => webdriver::command::PrintOrientation::Portrait, 202 | } 203 | } 204 | } 205 | 206 | /// The size of the printed page in centimeters. 207 | /// 208 | /// Default: [`PrintSize::A4`]. 209 | #[derive(Debug, Clone, PartialEq)] 210 | pub struct PrintSize { 211 | /// The width in centimeters. 212 | pub width: f64, 213 | /// The height in centimeters. 214 | pub height: f64, 215 | } 216 | 217 | impl PrintSize { 218 | /// The standard A4 paper size, which has the dimension of `21.0x29.7cm`. 219 | pub const A4: Self = Self { 220 | width: 21., 221 | height: 29.7, 222 | }; 223 | 224 | /// The standard US letter paper size, which has the dimension of `21.59x27.94cm`. 225 | pub const US_LETTER: Self = Self { 226 | width: 21.59, 227 | height: 27.94, 228 | }; 229 | 230 | /// The standard US legal paper size, which has the dimension of `21.59x35.56cm`. 231 | pub const US_LEGAL: Self = Self { 232 | width: 21.59, 233 | height: 35.56, 234 | }; 235 | 236 | /// The minimum page size allowed by the Webdriver2 standard, which is `2.54/72cm`. 237 | pub const MIN: Self = Self { 238 | // FIXME: use 2.54/72.0 with MSRV >= 1.82 239 | width: 0.036, 240 | height: 0.036, 241 | }; 242 | 243 | pub(crate) fn into_params(self) -> webdriver::command::PrintPage { 244 | webdriver::command::PrintPage { 245 | width: self.width, 246 | height: self.height, 247 | } 248 | } 249 | } 250 | 251 | impl Default for PrintSize { 252 | fn default() -> Self { 253 | Self::A4 254 | } 255 | } 256 | 257 | /// The range of the pages to print. 258 | #[derive(Debug, Clone, PartialEq, Eq)] 259 | pub struct PrintPageRange { 260 | range: RangeInclusive, 261 | } 262 | 263 | impl PrintPageRange { 264 | /// A single page to print. 265 | pub const fn single(page: u64) -> Self { 266 | Self { range: page..=page } 267 | } 268 | 269 | /// A range of pages to print. 270 | /// 271 | /// Returns None if the range start is greater than the range end. 272 | pub const fn range(range: RangeInclusive) -> Option { 273 | if *range.start() <= *range.end() { 274 | Some(Self { range }) 275 | } else { 276 | None 277 | } 278 | } 279 | 280 | pub(crate) fn into_params(self) -> webdriver::command::PrintPageRange { 281 | let (start, end) = self.range.into_inner(); 282 | 283 | if start == end { 284 | webdriver::command::PrintPageRange::Integer(start) 285 | } else { 286 | webdriver::command::PrintPageRange::Range(format!("{start}-{end}")) 287 | } 288 | } 289 | } 290 | 291 | /// The margins of the printed page in centimeters. 292 | /// 293 | /// Default: `1x1x1x1cm`. 294 | #[derive(Debug, Clone, PartialEq)] 295 | pub struct PrintMargins { 296 | /// The top margin in centimeters. 297 | pub top: f64, 298 | /// The bottom margin in centimeters. 299 | pub bottom: f64, 300 | /// The left margin in centimeters. 301 | pub left: f64, 302 | /// The right margin in centimeters. 303 | pub right: f64, 304 | } 305 | 306 | impl PrintMargins { 307 | pub(crate) fn into_params(self) -> webdriver::command::PrintMargins { 308 | webdriver::command::PrintMargins { 309 | top: self.top, 310 | bottom: self.bottom, 311 | left: self.left, 312 | right: self.right, 313 | } 314 | } 315 | } 316 | 317 | impl Default for PrintMargins { 318 | fn default() -> Self { 319 | Self { 320 | top: 1.0, 321 | bottom: 1.0, 322 | left: 1.0, 323 | right: 1.0, 324 | } 325 | } 326 | } 327 | 328 | #[cfg(test)] 329 | mod tests { 330 | use std::f64::{INFINITY, NAN}; 331 | 332 | use crate::{ 333 | error::PrintConfigurationError, 334 | wd::{PrintConfiguration, PrintMargins, PrintSize}, 335 | }; 336 | 337 | #[test] 338 | fn negative_print_configuration_dimensions() { 339 | let margins = PrintConfiguration::builder() 340 | .margins(PrintMargins { 341 | top: -1.0, 342 | bottom: 0.0, 343 | left: 1.0, 344 | right: 5.4, 345 | }) 346 | .build(); 347 | 348 | let size = PrintConfiguration::builder() 349 | .size(PrintSize { 350 | width: -1.0, 351 | height: 1.0, 352 | }) 353 | .build(); 354 | 355 | assert_eq!(margins, Err(PrintConfigurationError::NegativeDimensions)); 356 | assert_eq!(size, Err(PrintConfigurationError::NegativeDimensions)); 357 | } 358 | 359 | #[test] 360 | fn non_finite_print_configuration_dimensions() { 361 | let nan_margins = PrintConfiguration::builder() 362 | .margins(PrintMargins { 363 | top: NAN, 364 | bottom: 0.0, 365 | left: 1.0, 366 | right: 5.4, 367 | }) 368 | .build(); 369 | 370 | let nan_size = PrintConfiguration::builder() 371 | .size(PrintSize { 372 | width: NAN, 373 | height: 1.0, 374 | }) 375 | .build(); 376 | 377 | let infinite_margins = PrintConfiguration::builder() 378 | .margins(PrintMargins { 379 | top: INFINITY, 380 | bottom: 0.0, 381 | left: 1.0, 382 | right: 5.4, 383 | }) 384 | .build(); 385 | 386 | let infinite_size = PrintConfiguration::builder() 387 | .size(PrintSize { 388 | width: INFINITY, 389 | height: 1.0, 390 | }) 391 | .build(); 392 | 393 | assert_eq!( 394 | nan_margins, 395 | Err(PrintConfigurationError::NonFiniteDimensions) 396 | ); 397 | assert_eq!(nan_size, Err(PrintConfigurationError::NonFiniteDimensions)); 398 | assert_eq!( 399 | infinite_margins, 400 | Err(PrintConfigurationError::NonFiniteDimensions) 401 | ); 402 | assert_eq!( 403 | infinite_size, 404 | Err(PrintConfigurationError::NonFiniteDimensions) 405 | ); 406 | } 407 | 408 | #[test] 409 | fn overflow_print_configuration_dimensions() { 410 | let overflow = PrintConfiguration::builder() 411 | .size(PrintSize { 412 | width: 10.0, 413 | height: 5.0, 414 | }) 415 | .margins(PrintMargins { 416 | top: 1.0, 417 | bottom: 1.0, 418 | left: 5.0, 419 | right: 5.0, 420 | }) 421 | .build(); 422 | 423 | assert_eq!(overflow, Err(PrintConfigurationError::DimensionsOverflow)); 424 | } 425 | 426 | #[test] 427 | fn too_small_print_configuration_dimensions() { 428 | let size_to_small = PrintConfiguration::builder() 429 | .size(PrintSize { 430 | width: 0.01, 431 | height: 5.0, 432 | }) 433 | .build(); 434 | 435 | assert_eq!( 436 | size_to_small, 437 | Err(PrintConfigurationError::PrintSizeTooSmall) 438 | ); 439 | } 440 | } 441 | -------------------------------------------------------------------------------- /src/actions.rs: -------------------------------------------------------------------------------- 1 | //! Actions functionality for WebDriver. 2 | #[cfg(doc)] 3 | use crate::client::Client; 4 | use crate::elements::Element; 5 | #[cfg(doc)] 6 | use crate::key::Key; 7 | use std::fmt::Debug; 8 | use std::time::Duration; 9 | use webdriver::actions as WDActions; 10 | 11 | /// An action not associated with an input device (e.g. pause). 12 | /// 13 | /// See [17.4.1 General Actions](https://www.w3.org/TR/webdriver1/#general-actions) of the 14 | /// WebDriver standard. 15 | #[derive(Debug, Clone)] 16 | #[non_exhaustive] 17 | pub enum NullAction { 18 | /// Pause for the specified duration. 19 | Pause { 20 | /// The pause duration. 21 | duration: Duration, 22 | }, 23 | } 24 | 25 | impl NullAction { 26 | fn into_item(self) -> WDActions::NullActionItem { 27 | match self { 28 | NullAction::Pause { duration } => WDActions::NullActionItem::General( 29 | WDActions::GeneralAction::Pause(WDActions::PauseAction { 30 | duration: Some(duration.as_millis() as u64), 31 | }), 32 | ), 33 | } 34 | } 35 | } 36 | 37 | /// An action performed with a keyboard. 38 | /// 39 | /// See [17.4.2 Keyboard Actions](https://www.w3.org/TR/webdriver1/#keyboard-actions) of the 40 | /// WebDriver standard. 41 | #[derive(Debug, Clone)] 42 | #[non_exhaustive] 43 | pub enum KeyAction { 44 | /// Pause action. 45 | /// Useful for adding pauses between other key actions. 46 | Pause { 47 | /// The pause duration, given in milliseconds. 48 | duration: Duration, 49 | }, 50 | /// Key up action. 51 | Up { 52 | /// The key code, e.g. `'a'`. See the [`Key`] enum for special key codes. 53 | value: char, 54 | }, 55 | /// Key down action. 56 | Down { 57 | /// The key code, e.g. `'a'`. See the [`Key`] enum for special key codes. 58 | value: char, 59 | }, 60 | } 61 | 62 | impl KeyAction { 63 | fn into_item(self) -> WDActions::KeyActionItem { 64 | use webdriver::actions::{KeyAction as WDKeyAction, KeyDownAction, KeyUpAction}; 65 | match self { 66 | KeyAction::Pause { duration } => WDActions::KeyActionItem::General( 67 | WDActions::GeneralAction::Pause(WDActions::PauseAction { 68 | duration: Some(duration.as_millis() as u64), 69 | }), 70 | ), 71 | KeyAction::Up { value } => { 72 | WDActions::KeyActionItem::Key(WDKeyAction::Up(KeyUpAction { 73 | value: value.to_string(), 74 | })) 75 | } 76 | KeyAction::Down { value } => { 77 | WDActions::KeyActionItem::Key(WDKeyAction::Down(KeyDownAction { 78 | value: value.to_string(), 79 | })) 80 | } 81 | } 82 | } 83 | } 84 | 85 | /// Left mouse button constant for use with `PointerAction`. 86 | pub const MOUSE_BUTTON_LEFT: u64 = 0; 87 | 88 | /// Middle mouse button constant for use with `PointerAction`. 89 | pub const MOUSE_BUTTON_MIDDLE: u64 = 1; 90 | 91 | /// Right mouse button constant for use with `PointerAction`. 92 | pub const MOUSE_BUTTON_RIGHT: u64 = 2; 93 | 94 | /// An action performed with a pointer device. 95 | /// 96 | /// This can be a mouse, pen or touch device. 97 | /// 98 | /// See [17.4.3 Pointer Actions](https://www.w3.org/TR/webdriver1/#pointer-actions) of the 99 | /// WebDriver standard. 100 | #[derive(Debug, Clone)] 101 | #[non_exhaustive] 102 | pub enum PointerAction { 103 | /// Pause action. 104 | /// Useful for adding pauses between other key actions. 105 | Pause { 106 | /// The pause duration, given in milliseconds. 107 | duration: Duration, 108 | }, 109 | /// Pointer button down. 110 | Down { 111 | /// The mouse button index. 112 | /// 113 | /// The following constants are provided, but any mouse index can be used 114 | /// to represent the corresponding mouse button. 115 | /// - [`MOUSE_BUTTON_LEFT`] 116 | /// - [`MOUSE_BUTTON_MIDDLE`] 117 | /// - [`MOUSE_BUTTON_RIGHT`] 118 | button: u64, 119 | }, 120 | /// Pointer button up. 121 | Up { 122 | /// The mouse button index. 123 | /// 124 | /// The following constants are provided, but any mouse index can be used 125 | /// to represent the corresponding mouse button. 126 | /// - [`MOUSE_BUTTON_LEFT`] 127 | /// - [`MOUSE_BUTTON_MIDDLE`] 128 | /// - [`MOUSE_BUTTON_RIGHT`] 129 | button: u64, 130 | }, 131 | /// Move the pointer relative to the current position. 132 | /// 133 | /// The x and y offsets are relative to the current pointer position. 134 | MoveBy { 135 | /// The move duration. 136 | duration: Option, 137 | /// `x` offset, in pixels. 138 | x: f64, 139 | /// `y` offset, in pixels. 140 | y: f64, 141 | }, 142 | /// Move the pointer to a new position. 143 | /// 144 | /// The x and y offsets are relative to the top-left corner of the viewport. 145 | MoveTo { 146 | /// The move duration. 147 | duration: Option, 148 | /// `x` offset, in pixels. 149 | x: f64, 150 | /// `y` offset, in pixels. 151 | y: f64, 152 | }, 153 | /// Move the pointer to a position relative to the specified element. 154 | MoveToElement { 155 | /// The element to move the pointer in relation to. The `x` and `y` offsets are relative 156 | /// to this element's center position. 157 | element: Element, 158 | /// The move duration. 159 | duration: Option, 160 | /// `x` offset, in pixels. 161 | x: f64, 162 | /// `y` offset, in pixels. 163 | y: f64, 164 | }, 165 | /// Pointer cancel action. Used to cancel the current pointer action. 166 | Cancel, 167 | } 168 | 169 | impl PointerAction { 170 | fn into_item(self) -> WDActions::PointerActionItem { 171 | match self { 172 | PointerAction::Pause { duration } => WDActions::PointerActionItem::General( 173 | WDActions::GeneralAction::Pause(WDActions::PauseAction { 174 | duration: Some(duration.as_millis() as u64), 175 | }), 176 | ), 177 | PointerAction::Down { button } => WDActions::PointerActionItem::Pointer( 178 | WDActions::PointerAction::Down(WDActions::PointerDownAction { 179 | button, 180 | ..Default::default() 181 | }), 182 | ), 183 | PointerAction::Up { button } => WDActions::PointerActionItem::Pointer( 184 | WDActions::PointerAction::Up(WDActions::PointerUpAction { 185 | button, 186 | ..Default::default() 187 | }), 188 | ), 189 | PointerAction::MoveBy { duration, x, y } => WDActions::PointerActionItem::Pointer( 190 | WDActions::PointerAction::Move(WDActions::PointerMoveAction { 191 | duration: duration.map(|x| x.as_millis() as u64), 192 | origin: WDActions::PointerOrigin::Pointer, 193 | x, 194 | y, 195 | ..Default::default() 196 | }), 197 | ), 198 | PointerAction::MoveTo { duration, x, y } => WDActions::PointerActionItem::Pointer( 199 | WDActions::PointerAction::Move(WDActions::PointerMoveAction { 200 | duration: duration.map(|x| x.as_millis() as u64), 201 | origin: WDActions::PointerOrigin::Viewport, 202 | x, 203 | y, 204 | ..Default::default() 205 | }), 206 | ), 207 | PointerAction::MoveToElement { 208 | element, 209 | duration, 210 | x, 211 | y, 212 | } => WDActions::PointerActionItem::Pointer(WDActions::PointerAction::Move( 213 | WDActions::PointerMoveAction { 214 | duration: duration.map(|x| x.as_millis() as u64), 215 | origin: WDActions::PointerOrigin::Element(element.element), 216 | x, 217 | y, 218 | ..Default::default() 219 | }, 220 | )), 221 | PointerAction::Cancel => { 222 | WDActions::PointerActionItem::Pointer(WDActions::PointerAction::Cancel) 223 | } 224 | } 225 | } 226 | } 227 | 228 | /// A sequence containing [`Null` actions](NullAction). 229 | #[derive(Debug, Clone)] 230 | pub struct NullActions { 231 | /// A unique identifier to distinguish this input source from others. 232 | /// 233 | /// Choose a meaningful string as it may be useful for debugging. 234 | id: String, 235 | /// The list of actions for this sequence. 236 | actions: Vec, 237 | } 238 | 239 | impl NullActions { 240 | /// Create a new NullActions sequence. 241 | /// 242 | /// The id can be any string but must uniquely identify this input source. 243 | pub fn new(id: String) -> Self { 244 | Self { 245 | id, 246 | actions: Vec::new(), 247 | } 248 | } 249 | } 250 | 251 | impl From for ActionSequence { 252 | fn from(na: NullActions) -> Self { 253 | ActionSequence(WDActions::ActionSequence { 254 | id: na.id, 255 | actions: WDActions::ActionsType::Null { 256 | actions: na.actions.into_iter().map(|x| x.into_item()).collect(), 257 | }, 258 | }) 259 | } 260 | } 261 | 262 | /// A sequence containing [`Key` actions](KeyAction). 263 | #[derive(Debug, Clone)] 264 | pub struct KeyActions { 265 | /// A unique identifier to distinguish this input source from others. 266 | /// 267 | /// Choose a meaningful string as it may be useful for debugging. 268 | id: String, 269 | /// The list of actions for this sequence. 270 | actions: Vec, 271 | } 272 | 273 | impl KeyActions { 274 | /// Create a new KeyActions sequence. 275 | /// 276 | /// The id can be any string but must uniquely identify this input source. 277 | pub fn new(id: String) -> Self { 278 | Self { 279 | id, 280 | actions: Vec::new(), 281 | } 282 | } 283 | } 284 | 285 | impl From for ActionSequence { 286 | fn from(ka: KeyActions) -> Self { 287 | ActionSequence(WDActions::ActionSequence { 288 | id: ka.id, 289 | actions: WDActions::ActionsType::Key { 290 | actions: ka.actions.into_iter().map(|x| x.into_item()).collect(), 291 | }, 292 | }) 293 | } 294 | } 295 | 296 | /// A sequence containing [`Pointer` actions](PointerAction) for a mouse. 297 | #[derive(Debug, Clone)] 298 | pub struct MouseActions { 299 | /// A unique identifier to distinguish this input source from others. 300 | /// 301 | /// Choose a meaningful string as it may be useful for debugging. 302 | id: String, 303 | /// The list of actions for this sequence. 304 | actions: Vec, 305 | } 306 | 307 | impl MouseActions { 308 | /// Create a new `MouseActions` sequence. 309 | /// 310 | /// The id can be any string but must uniquely identify this input source. 311 | pub fn new(id: String) -> Self { 312 | Self { 313 | id, 314 | actions: Vec::new(), 315 | } 316 | } 317 | } 318 | 319 | impl From for ActionSequence { 320 | fn from(ma: MouseActions) -> Self { 321 | ActionSequence(WDActions::ActionSequence { 322 | id: ma.id, 323 | actions: WDActions::ActionsType::Pointer { 324 | parameters: WDActions::PointerActionParameters { 325 | pointer_type: WDActions::PointerType::Mouse, 326 | }, 327 | actions: ma.actions.into_iter().map(|x| x.into_item()).collect(), 328 | }, 329 | }) 330 | } 331 | } 332 | 333 | /// A sequence containing [`Pointer` actions](PointerAction) for a pen device. 334 | #[derive(Debug, Clone)] 335 | pub struct PenActions { 336 | /// A unique identifier to distinguish this input source from others. 337 | /// 338 | /// Choose a meaningful string as it may be useful for debugging. 339 | id: String, 340 | /// The list of actions for this sequence. 341 | actions: Vec, 342 | } 343 | 344 | impl PenActions { 345 | /// Create a new `PenActions` sequence. 346 | /// 347 | /// The id can be any string but must uniquely identify this input source. 348 | pub fn new(id: String) -> Self { 349 | Self { 350 | id, 351 | actions: Vec::new(), 352 | } 353 | } 354 | } 355 | 356 | impl From for ActionSequence { 357 | fn from(pa: PenActions) -> Self { 358 | ActionSequence(WDActions::ActionSequence { 359 | id: pa.id, 360 | actions: WDActions::ActionsType::Pointer { 361 | parameters: WDActions::PointerActionParameters { 362 | pointer_type: WDActions::PointerType::Pen, 363 | }, 364 | actions: pa.actions.into_iter().map(|x| x.into_item()).collect(), 365 | }, 366 | }) 367 | } 368 | } 369 | 370 | /// A sequence containing [`Pointer` actions](PointerAction) for a touch device. 371 | #[derive(Debug, Clone)] 372 | pub struct TouchActions { 373 | /// A unique identifier to distinguish this input source from others. 374 | /// 375 | /// Choose a meaningful string as it may be useful for debugging. 376 | id: String, 377 | /// The list of actions for this sequence. 378 | actions: Vec, 379 | } 380 | 381 | impl TouchActions { 382 | /// Create a new `TouchActions` sequence. 383 | /// 384 | /// The id can be any string but must uniquely identify this input source. 385 | pub fn new(id: String) -> Self { 386 | Self { 387 | id, 388 | actions: Vec::new(), 389 | } 390 | } 391 | } 392 | 393 | impl From for ActionSequence { 394 | fn from(ta: TouchActions) -> Self { 395 | ActionSequence(WDActions::ActionSequence { 396 | id: ta.id, 397 | actions: WDActions::ActionsType::Pointer { 398 | parameters: WDActions::PointerActionParameters { 399 | pointer_type: WDActions::PointerType::Touch, 400 | }, 401 | actions: ta.actions.into_iter().map(|x| x.into_item()).collect(), 402 | }, 403 | }) 404 | } 405 | } 406 | 407 | /// A sequence containing [`Wheel` actions](WheelAction) for a wheel device. 408 | #[derive(Debug, Clone)] 409 | pub struct WheelActions { 410 | /// A unique identifier to distinguish this input source from others. 411 | /// 412 | /// Choose a meaningful string as it may be useful for debugging. 413 | id: String, 414 | /// The list of actions for this sequence. 415 | actions: Vec, 416 | } 417 | 418 | impl WheelActions { 419 | /// Create a new `WheelActions` sequence. 420 | /// 421 | /// The id can be any string but must uniquely identify this input source. 422 | pub fn new(id: String) -> Self { 423 | Self { 424 | id, 425 | actions: Vec::new(), 426 | } 427 | } 428 | 429 | /// Pushes a new action. 430 | pub fn push(&mut self, action: WheelAction) { 431 | self.actions.push(action); 432 | } 433 | } 434 | 435 | impl From for ActionSequence { 436 | fn from(wa: WheelActions) -> Self { 437 | ActionSequence(WDActions::ActionSequence { 438 | id: wa.id, 439 | actions: WDActions::ActionsType::Wheel { 440 | actions: wa.actions.into_iter().map(|x| x.into_item()).collect(), 441 | }, 442 | }) 443 | } 444 | } 445 | 446 | /// An action performed with a wheel device. 447 | /// 448 | /// See [15.4.4 Wheel Actions](https://www.w3.org/TR/webdriver/#wheel-actions) of the 449 | /// WebDriver standard. 450 | #[derive(Debug, Clone)] 451 | #[non_exhaustive] 452 | pub enum WheelAction { 453 | /// Pause action. 454 | /// 455 | /// Useful for adding pauses between other key actions. 456 | Pause { 457 | /// The pause duration, given in milliseconds. 458 | duration: Duration, 459 | }, 460 | /// Wheel scroll event. 461 | Scroll { 462 | /// The scroll duration. 463 | duration: Option, 464 | /// `x` offset of the scroll origin, in pixels. 465 | x: i64, 466 | /// `y` offset of the scroll origin, in pixels. 467 | y: i64, 468 | /// The change of the number of pixels to be scrolled on the `x`-axis. 469 | delta_x: i64, 470 | /// The change of the number of pixels to be scrolled on the `y`-axis. 471 | delta_y: i64, 472 | }, 473 | } 474 | 475 | impl WheelAction { 476 | fn into_item(self) -> WDActions::WheelActionItem { 477 | match self { 478 | WheelAction::Pause { duration } => WDActions::WheelActionItem::General( 479 | WDActions::GeneralAction::Pause(WDActions::PauseAction { 480 | duration: Some(duration.as_millis() as u64), 481 | }), 482 | ), 483 | WheelAction::Scroll { 484 | x, 485 | y, 486 | delta_x, 487 | delta_y, 488 | duration, 489 | } => WDActions::WheelActionItem::Wheel(WDActions::WheelAction::Scroll( 490 | WDActions::WheelScrollAction { 491 | duration: duration.map(|d| d.as_millis() as u64), 492 | origin: WDActions::PointerOrigin::Viewport, 493 | x: Some(x), 494 | y: Some(y), 495 | deltaX: Some(delta_x), 496 | deltaY: Some(delta_y), 497 | }, 498 | )), 499 | } 500 | } 501 | } 502 | 503 | /// A sequence of actions to be performed. 504 | /// 505 | /// See the documentation for [`Actions`] for more details. 506 | #[derive(Debug)] 507 | pub struct ActionSequence(pub(crate) WDActions::ActionSequence); 508 | 509 | /// A source capable of providing inputs for a browser action chain. 510 | /// 511 | /// See [input source](https://www.w3.org/TR/webdriver1/#dfn-input-sources) in the 512 | /// WebDriver standard. 513 | /// 514 | /// Each sequence type implements `InputSource` which provides a `pause()` and a `then()` 515 | /// method. Each call to `pause()` or `then()` represents one tick for this sequence. 516 | pub trait InputSource: Into { 517 | /// The action type associated with this `InputSource`. 518 | type Action; 519 | 520 | /// Add a pause action to the sequence for this input source. 521 | #[must_use] 522 | fn pause(self, duration: Duration) -> Self; 523 | 524 | /// Add the specified action to the sequence for this input source. 525 | #[must_use] 526 | fn then(self, action: Self::Action) -> Self; 527 | } 528 | 529 | impl InputSource for NullActions { 530 | type Action = NullAction; 531 | 532 | fn pause(self, duration: Duration) -> Self { 533 | self.then(NullAction::Pause { duration }) 534 | } 535 | 536 | fn then(mut self, action: Self::Action) -> Self { 537 | self.actions.push(action); 538 | self 539 | } 540 | } 541 | 542 | impl InputSource for KeyActions { 543 | type Action = KeyAction; 544 | 545 | fn pause(self, duration: Duration) -> Self { 546 | self.then(KeyAction::Pause { duration }) 547 | } 548 | 549 | fn then(mut self, action: Self::Action) -> Self { 550 | self.actions.push(action); 551 | self 552 | } 553 | } 554 | 555 | impl InputSource for MouseActions { 556 | type Action = PointerAction; 557 | 558 | fn pause(self, duration: Duration) -> Self { 559 | self.then(PointerAction::Pause { duration }) 560 | } 561 | 562 | fn then(mut self, action: Self::Action) -> Self { 563 | self.actions.push(action); 564 | self 565 | } 566 | } 567 | 568 | impl InputSource for PenActions { 569 | type Action = PointerAction; 570 | 571 | fn pause(self, duration: Duration) -> Self { 572 | self.then(PointerAction::Pause { duration }) 573 | } 574 | 575 | fn then(mut self, action: Self::Action) -> Self { 576 | self.actions.push(action); 577 | self 578 | } 579 | } 580 | 581 | impl InputSource for TouchActions { 582 | type Action = PointerAction; 583 | 584 | fn pause(self, duration: Duration) -> Self { 585 | self.then(PointerAction::Pause { duration }) 586 | } 587 | 588 | fn then(mut self, action: Self::Action) -> Self { 589 | self.actions.push(action); 590 | self 591 | } 592 | } 593 | 594 | impl InputSource for WheelActions { 595 | type Action = WheelAction; 596 | 597 | fn pause(self, duration: Duration) -> Self { 598 | self.then(WheelAction::Pause { duration }) 599 | } 600 | 601 | fn then(mut self, action: Self::Action) -> Self { 602 | self.actions.push(action); 603 | self 604 | } 605 | } 606 | 607 | /// A list of action sequences to be performed via [`Client::perform_actions()`] 608 | /// 609 | /// An [`ActionSequence`] is a sequence of actions of a specific type. 610 | /// 611 | /// Multiple sequences can be represented as a table, where each row contains a 612 | /// sequence and each column is 1 "tick" of time. The duration of any given tick 613 | /// will be equal to the longest duration of any individual action in that column. 614 | /// 615 | /// See the following example diagram: 616 | /// 617 | /// ```ignore 618 | /// Tick -> 1 2 3 619 | /// |=================================================================== 620 | /// | KeyActions | Down | Up | Pause | 621 | /// |------------------------------------------------------------------- 622 | /// | PointerActions | Down | Pause (2 seconds) | Up | 623 | /// |------------------------------------------------------------------- 624 | /// | (other sequence) | Pause | Pause | Pause | 625 | /// |=================================================================== 626 | /// ``` 627 | /// 628 | /// At tick 1, both a `KeyAction::Down` and a `PointerAction::Down` are triggered 629 | /// simultaneously. 630 | /// 631 | /// At tick 2, only a `KeyAction::Up` is triggered. Meanwhile the pointer sequence 632 | /// has a `PointerAction::Pause` for 2 seconds. Note that tick 3 does not start 633 | /// until all of the actions from tick 2 have completed, including any pauses. 634 | /// 635 | /// At tick 3, only a `PointerAction::Up` is triggered. 636 | /// 637 | /// The bottom sequence is just to show that other sequences can be added. This could 638 | /// be any of `NullActions`, `KeyActions` or `PointerActions`. There is no theoretical 639 | /// limit to the number of sequences that can be specified. 640 | #[derive(Debug, Default)] 641 | pub struct Actions { 642 | pub(crate) sequences: Vec, 643 | } 644 | 645 | impl Actions { 646 | /// Append the specified sequence to the list of sequences. 647 | #[must_use] 648 | pub fn and(mut self, sequence: impl Into) -> Self { 649 | self.sequences.push(sequence.into()); 650 | self 651 | } 652 | } 653 | 654 | impl From for Actions 655 | where 656 | T: Into, 657 | { 658 | fn from(sequence: T) -> Self { 659 | Self { 660 | sequences: vec![sequence.into()], 661 | } 662 | } 663 | } 664 | 665 | impl From> for Actions 666 | where 667 | T: Into, 668 | { 669 | fn from(sequences: Vec) -> Self { 670 | Self { 671 | sequences: sequences.into_iter().map(|x| x.into()).collect(), 672 | } 673 | } 674 | } 675 | -------------------------------------------------------------------------------- /src/elements.rs: -------------------------------------------------------------------------------- 1 | //! Types used to represent particular elements on a page. 2 | 3 | use crate::wd::Locator; 4 | use crate::{error, Client}; 5 | use base64::Engine; 6 | use serde::Serialize; 7 | use serde_json::Value as Json; 8 | use std::fmt::{Display, Formatter}; 9 | use std::ops::Deref; 10 | use webdriver::command::WebDriverCommand; 11 | use webdriver::common::FrameId; 12 | 13 | /// Web element reference. 14 | /// 15 | /// > Each element has an associated web element reference that uniquely identifies the element 16 | /// > across all browsing contexts. The web element reference for every element representing the 17 | /// > same element must be the same. It must be a string, and should be the result of generating 18 | /// > a UUID. 19 | /// 20 | /// See [11. Elements](https://www.w3.org/TR/webdriver1/#elements) of the WebDriver standard. 21 | #[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)] 22 | pub struct ElementRef(String); 23 | 24 | impl Display for ElementRef { 25 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 26 | self.0.fmt(f) 27 | } 28 | } 29 | 30 | impl AsRef for ElementRef { 31 | fn as_ref(&self) -> &str { 32 | &self.0 33 | } 34 | } 35 | 36 | impl Deref for ElementRef { 37 | type Target = str; 38 | 39 | fn deref(&self) -> &Self::Target { 40 | &self.0 41 | } 42 | } 43 | 44 | impl From for String { 45 | fn from(id: ElementRef) -> Self { 46 | id.0 47 | } 48 | } 49 | 50 | impl From for ElementRef { 51 | fn from(s: String) -> Self { 52 | ElementRef(s) 53 | } 54 | } 55 | 56 | /// A single DOM element on the current page. 57 | /// 58 | /// Note that there is a lot of subtlety in how you can interact with an element through WebDriver, 59 | /// which [the WebDriver standard goes into detail on](https://www.w3.org/TR/webdriver1/#elements). 60 | /// The same goes for inspecting [element state](https://www.w3.org/TR/webdriver1/#element-state). 61 | #[derive(Clone, Debug, Serialize)] 62 | pub struct Element { 63 | /// The high-level WebDriver client, for sending commands. 64 | #[serde(skip_serializing)] 65 | pub(crate) client: Client, 66 | /// The encapsulated WebElement struct. 67 | #[serde(flatten)] 68 | pub(crate) element: webdriver::common::WebElement, 69 | } 70 | 71 | impl Element { 72 | /// Construct an `Element` with the specified element id. 73 | /// The element id is the id given by the webdriver. 74 | pub fn from_element_id(client: Client, element_id: ElementRef) -> Self { 75 | Self { 76 | client, 77 | element: webdriver::common::WebElement(element_id.0), 78 | } 79 | } 80 | 81 | /// Get back the [`Client`] hosting this `Element`. 82 | pub fn client(self) -> Client { 83 | self.client 84 | } 85 | 86 | /// Get the element id as given by the webdriver. 87 | pub fn element_id(&self) -> ElementRef { 88 | ElementRef(self.element.0.clone()) 89 | } 90 | } 91 | 92 | /// An HTML form on the current page. 93 | #[derive(Clone, Debug)] 94 | pub struct Form { 95 | pub(crate) client: Client, 96 | pub(crate) form: webdriver::common::WebElement, 97 | } 98 | 99 | /// [Command Contexts](https://www.w3.org/TR/webdriver1/#command-contexts) 100 | impl Element { 101 | /// Switches to the frame contained within the element. 102 | /// 103 | /// See [10.5 Switch To Frame](https://www.w3.org/TR/webdriver1/#switch-to-frame) of the 104 | /// WebDriver standard. 105 | #[cfg_attr(docsrs, doc(alias = "Switch To Frame"))] 106 | pub async fn enter_frame(&self) -> Result<(), error::CmdError> { 107 | let params = webdriver::command::SwitchToFrameParameters { 108 | id: FrameId::Element(self.element.clone()), 109 | }; 110 | self.client 111 | .issue(WebDriverCommand::SwitchToFrame(params)) 112 | .await?; 113 | Ok(()) 114 | } 115 | } 116 | 117 | /// [Element Retrieval](https://www.w3.org/TR/webdriver1/#element-retrieval) 118 | impl Element { 119 | /// Find the first descendant element that matches the given [`Locator`]. 120 | /// 121 | /// See [12.4 Find Element From 122 | /// Element](https://www.w3.org/TR/webdriver1/#find-element-from-element) of the WebDriver 123 | /// standard. 124 | #[cfg_attr(docsrs, doc(alias = "Find Element From Element"))] 125 | pub async fn find(&self, search: Locator<'_>) -> Result { 126 | let res = self 127 | .client 128 | .issue(WebDriverCommand::FindElementElement( 129 | self.element.clone(), 130 | search.into_parameters(), 131 | )) 132 | .await?; 133 | let e = self.client.parse_lookup(res)?; 134 | Ok(Element { 135 | client: self.client.clone(), 136 | element: e, 137 | }) 138 | } 139 | 140 | /// Find all descendant elements that match the given [`Locator`]. 141 | /// 142 | /// See [12.5 Find Elements From 143 | /// Element](https://www.w3.org/TR/webdriver1/#find-elements-from-element) of the WebDriver 144 | /// standard. 145 | #[cfg_attr(docsrs, doc(alias = "Find Elements From Element"))] 146 | pub async fn find_all(&self, search: Locator<'_>) -> Result, error::CmdError> { 147 | let res = self 148 | .client 149 | .issue(WebDriverCommand::FindElementElements( 150 | self.element.clone(), 151 | search.into_parameters(), 152 | )) 153 | .await?; 154 | let array = self.client.parse_lookup_all(res)?; 155 | Ok(array 156 | .into_iter() 157 | .map(move |e| Element { 158 | client: self.client.clone(), 159 | element: e, 160 | }) 161 | .collect()) 162 | } 163 | } 164 | 165 | /// [Element State](https://www.w3.org/TR/webdriver1/#element-state) 166 | impl Element { 167 | /// Return true if the element is currently selected. 168 | /// 169 | /// See [13.1 Is Element Selected](https://www.w3.org/TR/webdriver1/#is-element-selected) 170 | /// of the WebDriver standard. 171 | pub async fn is_selected(&self) -> Result { 172 | let cmd = WebDriverCommand::IsSelected(self.element.clone()); 173 | match self.client.issue(cmd).await? { 174 | Json::Bool(v) => Ok(v), 175 | v => Err(error::CmdError::NotW3C(v)), 176 | } 177 | } 178 | 179 | /// Return true if the element is currently enabled. 180 | /// 181 | /// See [13.8 Is Element Enabled](https://www.w3.org/TR/webdriver1/#is-element-enabled) 182 | /// of the WebDriver standard. 183 | pub async fn is_enabled(&self) -> Result { 184 | let cmd = WebDriverCommand::IsEnabled(self.element.clone()); 185 | match self.client.issue(cmd).await? { 186 | Json::Bool(v) => Ok(v), 187 | v => Err(error::CmdError::NotW3C(v)), 188 | } 189 | } 190 | 191 | /// Return true if the element is currently displayed. 192 | /// 193 | /// See [Element Displayedness](https://www.w3.org/TR/webdriver1/#element-displayedness) 194 | /// of the WebDriver standard. 195 | pub async fn is_displayed(&self) -> Result { 196 | let cmd = WebDriverCommand::IsDisplayed(self.element.clone()); 197 | match self.client.issue(cmd).await? { 198 | Json::Bool(v) => Ok(v), 199 | v => Err(error::CmdError::NotW3C(v)), 200 | } 201 | } 202 | 203 | /// Look up an [attribute] value for this element by name. 204 | /// 205 | /// `Ok(None)` is returned if the element does not have the given attribute. 206 | /// 207 | /// See [13.2 Get Element Attribute](https://www.w3.org/TR/webdriver1/#get-element-attribute) 208 | /// of the WebDriver standard. 209 | /// 210 | /// [attribute]: https://dom.spec.whatwg.org/#concept-attribute 211 | #[cfg_attr(docsrs, doc(alias = "Get Element Attribute"))] 212 | pub async fn attr(&self, attribute: &str) -> Result, error::CmdError> { 213 | let cmd = 214 | WebDriverCommand::GetElementAttribute(self.element.clone(), attribute.to_string()); 215 | match self.client.issue(cmd).await? { 216 | Json::String(v) => Ok(Some(v)), 217 | Json::Null => Ok(None), 218 | v => Err(error::CmdError::NotW3C(v)), 219 | } 220 | } 221 | 222 | /// Look up a DOM [property] for this element by name. 223 | /// 224 | /// `Ok(None)` is returned if the element does not have the given property. 225 | /// 226 | /// Boolean properties such as "checked" will be returned as the String "true" or "false". 227 | /// 228 | /// See [13.3 Get Element Property](https://www.w3.org/TR/webdriver1/#get-element-property) 229 | /// of the WebDriver standard. 230 | /// 231 | /// [property]: https://www.ecma-international.org/ecma-262/5.1/#sec-8.12.1 232 | #[cfg_attr(docsrs, doc(alias = "Get Element Property"))] 233 | pub async fn prop(&self, prop: &str) -> Result, error::CmdError> { 234 | let cmd = WebDriverCommand::GetElementProperty(self.element.clone(), prop.to_string()); 235 | match self.client.issue(cmd).await? { 236 | Json::String(v) => Ok(Some(v)), 237 | Json::Bool(b) => Ok(Some(b.to_string())), 238 | Json::Null => Ok(None), 239 | v => Err(error::CmdError::NotW3C(v)), 240 | } 241 | } 242 | 243 | /// Look up the [computed value] of a CSS property for this element by name. 244 | /// 245 | /// `Ok(String::new())` is returned if the the given CSS property is not found. 246 | /// 247 | /// See [13.4 Get Element CSS Value](https://www.w3.org/TR/webdriver1/#get-element-css-value) 248 | /// of the WebDriver standard. 249 | /// 250 | /// [computed value]: https://drafts.csswg.org/css-cascade-4/#computed-value 251 | #[cfg_attr(docsrs, doc(alias = "Get Element CSS Value"))] 252 | pub async fn css_value(&self, prop: &str) -> Result { 253 | let cmd = WebDriverCommand::GetCSSValue(self.element.clone(), prop.to_string()); 254 | match self.client.issue(cmd).await? { 255 | Json::String(v) => Ok(v), 256 | v => Err(error::CmdError::NotW3C(v)), 257 | } 258 | } 259 | 260 | /// Retrieve the text contents of this element. 261 | /// 262 | /// See [13.5 Get Element Text](https://www.w3.org/TR/webdriver1/#get-element-text) 263 | /// of the WebDriver standard. 264 | #[cfg_attr(docsrs, doc(alias = "Get Element Text"))] 265 | pub async fn text(&self) -> Result { 266 | let cmd = WebDriverCommand::GetElementText(self.element.clone()); 267 | match self.client.issue(cmd).await? { 268 | Json::String(v) => Ok(v), 269 | v => Err(error::CmdError::NotW3C(v)), 270 | } 271 | } 272 | 273 | /// Retrieve the tag name of this element. 274 | /// 275 | /// See [13.6 Get Element Tag Name](https://www.w3.org/TR/webdriver1/#get-element-tag-name) 276 | /// of the WebDriver standard. 277 | #[cfg_attr(docsrs, doc(alias = "Get Element Tag Name"))] 278 | pub async fn tag_name(&self) -> Result { 279 | let cmd = WebDriverCommand::GetElementTagName(self.element.clone()); 280 | match self.client.issue(cmd).await? { 281 | Json::String(v) => Ok(v), 282 | v => Err(error::CmdError::NotW3C(v)), 283 | } 284 | } 285 | 286 | /// Gets the x, y, width, and height properties of the current element. 287 | /// 288 | /// See [13.7 Get Element Rect](https://www.w3.org/TR/webdriver1/#dfn-get-element-rect) of the 289 | /// WebDriver standard. 290 | #[cfg_attr(docsrs, doc(alias = "Get Element Rect"))] 291 | pub async fn rectangle(&self) -> Result<(f64, f64, f64, f64), error::CmdError> { 292 | match self 293 | .client 294 | .issue(WebDriverCommand::GetElementRect(self.element.clone())) 295 | .await? 296 | { 297 | Json::Object(mut obj) => { 298 | let x = match obj.remove("x").and_then(|x| x.as_f64()) { 299 | Some(x) => x, 300 | None => return Err(error::CmdError::NotW3C(Json::Object(obj))), 301 | }; 302 | 303 | let y = match obj.remove("y").and_then(|y| y.as_f64()) { 304 | Some(y) => y, 305 | None => return Err(error::CmdError::NotW3C(Json::Object(obj))), 306 | }; 307 | 308 | let width = match obj.remove("width").and_then(|width| width.as_f64()) { 309 | Some(width) => width, 310 | None => return Err(error::CmdError::NotW3C(Json::Object(obj))), 311 | }; 312 | 313 | let height = match obj.remove("height").and_then(|height| height.as_f64()) { 314 | Some(height) => height, 315 | None => return Err(error::CmdError::NotW3C(Json::Object(obj))), 316 | }; 317 | 318 | Ok((x, y, width, height)) 319 | } 320 | v => Err(error::CmdError::NotW3C(v)), 321 | } 322 | } 323 | 324 | /// Retrieve the HTML contents of this element. 325 | /// 326 | /// `inner` dictates whether the wrapping node's HTML is excluded or not. For example, take the 327 | /// HTML: 328 | /// 329 | /// ```html 330 | ///

331 | /// ``` 332 | /// 333 | /// With `inner = true`, `
` would be returned. With `inner = false`, 334 | /// `

` would be returned instead. 335 | #[cfg_attr(docsrs, doc(alias = "innerHTML"))] 336 | #[cfg_attr(docsrs, doc(alias = "outerHTML"))] 337 | pub async fn html(&self, inner: bool) -> Result { 338 | let prop = if inner { "innerHTML" } else { "outerHTML" }; 339 | Ok(self.prop(prop).await?.unwrap()) 340 | } 341 | } 342 | 343 | /// [Element Interaction](https://www.w3.org/TR/webdriver1/#element-interaction) 344 | impl Element { 345 | /// Simulate the user clicking on this element. 346 | /// 347 | /// See [14.1 Element Click](https://www.w3.org/TR/webdriver1/#element-click) of the WebDriver 348 | /// standard. 349 | #[cfg_attr(docsrs, doc(alias = "Element Click"))] 350 | pub async fn click(&self) -> Result<(), error::CmdError> { 351 | let cmd = WebDriverCommand::ElementClick(self.element.clone()); 352 | let r = self.client.issue(cmd).await?; 353 | if r.is_null() || r.as_object().map(|o| o.is_empty()).unwrap_or(false) { 354 | // geckodriver returns {} :( 355 | Ok(()) 356 | } else { 357 | Err(error::CmdError::NotW3C(r)) 358 | } 359 | } 360 | 361 | /// Clear this element. 362 | /// 363 | /// See [14.2 Element Clear](https://www.w3.org/TR/webdriver1/#element-clear) of the WebDriver 364 | /// standard. 365 | #[cfg_attr(docsrs, doc(alias = "Element Clear"))] 366 | pub async fn clear(&self) -> Result<(), error::CmdError> { 367 | let cmd = WebDriverCommand::ElementClear(self.element.clone()); 368 | let r = self.client.issue(cmd).await?; 369 | if r.is_null() { 370 | Ok(()) 371 | } else { 372 | Err(error::CmdError::NotW3C(r)) 373 | } 374 | } 375 | 376 | /// Simulate the user sending keys to this element. 377 | /// 378 | /// This operation scrolls into view the form control element and then sends the provided keys 379 | /// to the element. In case the element is not keyboard-interactable, an element not 380 | /// interactable error is returned. 381 | /// 382 | /// See [14.3 Element Send Keys](https://www.w3.org/TR/webdriver1/#element-send-keys) of the 383 | /// WebDriver standard. 384 | #[cfg_attr(docsrs, doc(alias = "Element Send Keys"))] 385 | pub async fn send_keys(&self, text: &str) -> Result<(), error::CmdError> { 386 | let cmd = WebDriverCommand::ElementSendKeys( 387 | self.element.clone(), 388 | webdriver::command::SendKeysParameters { 389 | text: text.to_owned(), 390 | }, 391 | ); 392 | let r = self.client.issue(cmd).await?; 393 | if r.is_null() { 394 | Ok(()) 395 | } else { 396 | Err(error::CmdError::NotW3C(r)) 397 | } 398 | } 399 | } 400 | 401 | /// [Screen Capture](https://www.w3.org/TR/webdriver1/#screen-capture) 402 | impl Element { 403 | /// Get a PNG-encoded screenshot of this element. 404 | /// 405 | /// See [19.2 Take Element Screenshot](https://www.w3.org/TR/webdriver1/#dfn-take-element-screenshot) of the WebDriver 406 | /// standard. 407 | #[cfg_attr(docsrs, doc(alias = "Take Element Screenshot"))] 408 | pub async fn screenshot(&self) -> Result, error::CmdError> { 409 | let src = self 410 | .client 411 | .issue(WebDriverCommand::TakeElementScreenshot( 412 | self.element.clone(), 413 | )) 414 | .await?; 415 | if let Some(src) = src.as_str() { 416 | base64::engine::general_purpose::STANDARD 417 | .decode(src) 418 | .map_err(error::CmdError::ImageDecodeError) 419 | } else { 420 | Err(error::CmdError::NotW3C(src)) 421 | } 422 | } 423 | } 424 | 425 | /// Higher-level operations. 426 | impl Element { 427 | /// Follow the `href` target of the element matching the given CSS selector *without* causing a 428 | /// click interaction. 429 | pub async fn follow(&self) -> Result<(), error::CmdError> { 430 | let cmd = WebDriverCommand::GetElementAttribute(self.element.clone(), "href".to_string()); 431 | let href = self.client.issue(cmd).await?; 432 | let href = match href { 433 | Json::String(v) => v, 434 | Json::Null => { 435 | let e = error::WebDriver::new( 436 | error::ErrorStatus::InvalidArgument, 437 | "cannot follow element without href attribute", 438 | ); 439 | return Err(error::CmdError::Standard(e)); 440 | } 441 | v => return Err(error::CmdError::NotW3C(v)), 442 | }; 443 | 444 | let url = self.client.current_url_().await?; 445 | let href = url.join(&href)?; 446 | self.client.goto(href.as_str()).await?; 447 | Ok(()) 448 | } 449 | 450 | /// Find and click an `