Add ability to run tests on remote webdriver. (#1744)

* Add ability run tests on remote webdriver

* Add parsing `webdriver.json` for configure browser capabilities

* Add docs for configuring of browser capabilities

* Remove webdriver dependency
This commit is contained in:
Kirguir
2019-09-19 17:00:51 +03:00
committed by Alex Crichton
parent 04c9b32e34
commit eeebec0765
2 changed files with 218 additions and 94 deletions

View File

@ -2,9 +2,11 @@ use crate::shell::Shell;
use curl::easy::Easy; use curl::easy::Easy;
use failure::{bail, format_err, Error, ResultExt}; use failure::{bail, format_err, Error, ResultExt};
use log::{debug, warn}; use log::{debug, warn};
use rouille::url::Url;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{self, json}; use serde_json::{self, json, Map, Value as Json};
use std::env; use std::env;
use std::fs::File;
use std::io::{self, Read}; use std::io::{self, Read};
use std::net::{SocketAddr, TcpListener, TcpStream}; use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -12,6 +14,40 @@ use std::process::{Child, Command, Stdio};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Options that can use to customize and configure a WebDriver session.
type Capabilities = Map<String, Json>;
/// Wrapper for [`Capabilities`] used in `--w3c` mode.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct SpecNewSessionParameters {
#[serde(rename = "alwaysMatch", default = "Capabilities::default")]
pub always_match: Capabilities,
#[serde(rename = "firstMatch", default = "first_match_default")]
pub first_match: Vec<Capabilities>,
}
impl Default for SpecNewSessionParameters {
fn default() -> Self {
Self {
always_match: Capabilities::new(),
first_match: vec![Capabilities::new()],
}
}
}
fn first_match_default() -> Vec<Capabilities> {
vec![Capabilities::default()]
}
/// Wrapper for [`Capabilities`] used in `--legacy` mode.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
pub struct LegacyNewSessionParameters {
#[serde(rename = "desiredCapabilities", default = "Capabilities::default")]
pub desired: Capabilities,
#[serde(rename = "requiredCapabilities", default = "Capabilities::default")]
pub required: Capabilities,
}
/// Execute a headless browser tests against a server running on `server` /// Execute a headless browser tests against a server running on `server`
/// address. /// address.
/// ///
@ -20,13 +56,11 @@ use std::time::{Duration, Instant};
/// etc. It will return `Ok` if all tests finish successfully, and otherwise it /// etc. It will return `Ok` if all tests finish successfully, and otherwise it
/// will return an error if some tests failed. /// will return an error if some tests failed.
pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> { pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> {
let (driver, args, mut client_args) = Driver::find()?; let driver = Driver::find()?;
println!( let mut drop_log: Box<dyn FnMut()> = Box::new(|| ());
"Running headless tests in {} with `{}`", let driver_url = match driver.location() {
driver.browser(), Locate::Remote(url) => Ok(url.clone()),
driver.path().display() Locate::Local((path, args)) => {
);
// Allow tests to run in parallel (in theory) by finding any open port // Allow tests to run in parallel (in theory) by finding any open port
// available for our driver. We can't bind the port for the driver, but // available for our driver. We can't bind the port for the driver, but
// hopefully the OS gives this invocation unique ports across processes // hopefully the OS gives this invocation unique ports across processes
@ -34,10 +68,11 @@ pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> {
// Spawn the driver binary, collecting its stdout/stderr in separate // Spawn the driver binary, collecting its stdout/stderr in separate
// threads. We'll print this output later. // threads. We'll print this output later.
let mut cmd = Command::new(driver.path()); let mut cmd = Command::new(path);
cmd.args(&args) cmd.args(args)
.arg(format!("--port={}", driver_addr.port().to_string())); .arg(format!("--port={}", driver_addr.port().to_string()));
let mut child = BackgroundChild::spawn(driver.path(), &mut cmd, shell)?; let mut child = BackgroundChild::spawn(&path, &mut cmd, shell)?;
drop_log = Box::new(move || child.print_stdio_on_drop = false);
// Wait for the driver to come online and bind its port before we try to // Wait for the driver to come online and bind its port before we try to
// connect to it. // connect to it.
@ -54,16 +89,35 @@ pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> {
if !bound { if !bound {
bail!("driver failed to bind port during startup") bail!("driver failed to bind port during startup")
} }
Url::parse(&format!("http://{}", driver_addr)).map_err(Error::from)
}
}?;
println!(
"Running headless tests in {} on `{}`",
driver.browser(),
driver_url.as_str(),
);
let mut client = Client { let mut client = Client {
handle: Easy::new(), handle: Easy::new(),
driver_addr, driver_url,
session: None, session: None,
}; };
println!("Try find `webdriver.json` for configure browser's capabilities:");
let capabilities: Capabilities = match File::open("webdriver.json") {
Ok(file) => {
println!("Ok");
serde_json::from_reader(file)
}
Err(_) => {
println!("Not found");
Ok(Capabilities::new())
}
}?;
shell.status("Starting new webdriver session..."); shell.status("Starting new webdriver session...");
// Allocate a new session with the webdriver protocol, and once we've done // Allocate a new session with the webdriver protocol, and once we've done
// so schedule the browser to get closed with a call to `close_window`. // so schedule the browser to get closed with a call to `close_window`.
let id = client.new_session(&driver, &mut client_args)?; let id = client.new_session(&driver, capabilities)?;
client.session = Some(id.clone()); client.session = Some(id.clone());
// Visit our local server to open up the page that runs tests, and then get // Visit our local server to open up the page that runs tests, and then get
@ -114,7 +168,7 @@ pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> {
// If the tests harness finished (either successfully or unsuccessfully) // If the tests harness finished (either successfully or unsuccessfully)
// then in theory all the info needed to debug the failure is in its own // then in theory all the info needed to debug the failure is in its own
// output, so we shouldn't need the driver logs to get printed. // output, so we shouldn't need the driver logs to get printed.
child.print_stdio_on_drop = false; drop_log();
} else { } else {
println!("failed to detect test as having been run"); println!("failed to detect test as having been run");
if output.len() > 0 { if output.len() > 0 {
@ -136,48 +190,66 @@ pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> {
} }
enum Driver { enum Driver {
Gecko(PathBuf), Gecko(Locate),
Safari(PathBuf), Safari(Locate),
Chrome(PathBuf), Chrome(Locate),
}
enum Locate {
Local((PathBuf, Vec<String>)),
Remote(Url),
} }
impl Driver { impl Driver {
/// Attempts to find an appropriate WebDriver server binary to execute tests /// Attempts to find an appropriate remote WebDriver server or server binary
/// with. Performs a number of heuristics to find one available, including: /// to execute tests with.
/// Performs a number of heuristics to find one available, including:
/// ///
/// * Env vars like `GECKODRIVER_REMOTE` address of remote webdriver.
/// * Env vars like `GECKODRIVER` point to the path to a binary to execute. /// * Env vars like `GECKODRIVER` point to the path to a binary to execute.
/// * Otherwise, `PATH` is searched for an appropriate binary. /// * Otherwise, `PATH` is searched for an appropriate binary.
/// ///
/// In both cases a lists of auxiliary arguments is also returned which is /// In the last two cases a list of auxiliary arguments is also returned
/// configured through env vars like `GECKODRIVER_ARGS` and /// which is configured through env vars like `GECKODRIVER_ARGS` to support
/// `GECKODRIVER_CLIENT_ARGS` to support extra arguments to invocation the /// extra arguments to the driver's invocation.
/// driver and a browser respectively. fn find() -> Result<Driver, Error> {
fn find() -> Result<(Driver, Vec<String>, Vec<String>), Error> { let env_args = |name: &str| {
let env_vars = |name: String| { env::var(format!("{}_ARGS", name.to_uppercase()))
env::var(name)
.unwrap_or_default() .unwrap_or_default()
.split_whitespace() .split_whitespace()
.map(|s| s.to_string()) .map(|s| s.to_string())
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
let env_args = |name: &str| env_vars(format!("{}_ARGS", name.to_uppercase()));
let env_client_args = |name: &str| env_vars(format!("{}_CLIENT_ARGS", name.to_uppercase()));
let drivers = [ let drivers = [
("geckodriver", Driver::Gecko as fn(PathBuf) -> Driver), ("geckodriver", Driver::Gecko as fn(Locate) -> Driver),
("safaridriver", Driver::Safari as fn(PathBuf) -> Driver), ("safaridriver", Driver::Safari as fn(Locate) -> Driver),
("chromedriver", Driver::Chrome as fn(PathBuf) -> Driver), ("chromedriver", Driver::Chrome as fn(Locate) -> Driver),
]; ];
// First up, if env vars like GECKODRIVER are present, use those to // First up, if env vars like GECKODRIVER_REMOTE are present, use those
// allow forcing usage of a particular driver. // to allow forcing usage of a particular remote driver.
for (driver, ctor) in drivers.iter() {
let env = format!("{}_REMOTE", driver.to_uppercase());
let url = match env::var(&env) {
Ok(var) => match Url::parse(&var) {
Ok(url) => url,
Err(_) => continue,
},
Err(_) => continue,
};
return Ok(ctor(Locate::Remote(url)));
}
// Next, if env vars like GECKODRIVER are present, use those to
// allow forcing usage of a particular local driver.
for (driver, ctor) in drivers.iter() { for (driver, ctor) in drivers.iter() {
let env = driver.to_uppercase(); let env = driver.to_uppercase();
let path = match env::var_os(&env) { let path = match env::var_os(&env) {
Some(path) => path, Some(path) => path,
None => continue, None => continue,
}; };
return Ok((ctor(path.into()), env_args(driver), env_client_args(driver))); return Ok(ctor(Locate::Local((path.into(), env_args(driver)))));
} }
// Next, check PATH. If we can find any supported driver, use that by // Next, check PATH. If we can find any supported driver, use that by
@ -188,11 +260,11 @@ impl Driver {
.with_extension(env::consts::EXE_EXTENSION) .with_extension(env::consts::EXE_EXTENSION)
.exists() .exists()
}); });
let (name, ctor) = match found { let (driver, ctor) = match found {
Some(p) => p, Some(p) => p,
None => continue, None => continue,
}; };
return Ok((ctor(name.into()), env_args(name), env_client_args(name))); return Ok(ctor(Locate::Local((path.into(), env_args(driver)))));
} }
// TODO: download an appropriate driver? How to know which one to // TODO: download an appropriate driver? How to know which one to
@ -200,10 +272,11 @@ impl Driver {
bail!( bail!(
"\ "\
failed to find a suitable WebDriver binary to drive headless testing; to failed to find a suitable WebDriver binary or remote running WebDriver to drive
configure the location of the webdriver binary you can use environment headless testing; to configure the location of the webdriver binary you can use
variables like `GECKODRIVER=/path/to/geckodriver` or make sure that the binary environment variables like `GECKODRIVER=/path/to/geckodriver` or make sure that
is in `PATH` the binary is in `PATH`; to configure the address of remote webdriver you can
use environment variables like `GECKODRIVER_REMOTE=http://remote.host/`
This crate currently supports `geckodriver`, `chromedriver`, and `safaridriver`, This crate currently supports `geckodriver`, `chromedriver`, and `safaridriver`,
although more driver support may be added! You can download these at: although more driver support may be added! You can download these at:
@ -223,14 +296,6 @@ an issue against rustwasm/wasm-bindgen!
) )
} }
fn path(&self) -> &Path {
match self {
Driver::Gecko(path) => path,
Driver::Safari(path) => path,
Driver::Chrome(path) => path,
}
}
fn browser(&self) -> &str { fn browser(&self) -> &str {
match self { match self {
Driver::Gecko(_) => "Firefox", Driver::Gecko(_) => "Firefox",
@ -238,11 +303,19 @@ an issue against rustwasm/wasm-bindgen!
Driver::Chrome(_) => "Chrome", Driver::Chrome(_) => "Chrome",
} }
} }
fn location(&self) -> &Locate {
match self {
Driver::Gecko(locate) => locate,
Driver::Safari(locate) => locate,
Driver::Chrome(locate) => locate,
}
}
} }
struct Client { struct Client {
handle: Easy, handle: Easy,
driver_addr: SocketAddr, driver_url: Url,
session: Option<String>, session: Option<String>,
} }
@ -257,7 +330,7 @@ enum Method<'a> {
// copied the `webdriver-client` crate when writing the below bindings. // copied the `webdriver-client` crate when writing the below bindings.
impl Client { impl Client {
fn new_session(&mut self, driver: &Driver, args: &mut Vec<String>) -> Result<String, Error> { fn new_session(&mut self, driver: &Driver, mut cap: Capabilities) -> Result<String, Error> {
match driver { match driver {
Driver::Gecko(_) => { Driver::Gecko(_) => {
#[derive(Deserialize)] #[derive(Deserialize)]
@ -270,15 +343,21 @@ impl Client {
#[serde(rename = "sessionId")] #[serde(rename = "sessionId")]
session_id: String, session_id: String,
} }
args.push("-headless".to_string()); cap.entry("moz:firefoxOptions".to_string())
.or_insert_with(|| Json::Object(serde_json::Map::new()))
.as_object_mut()
.expect("moz:firefoxOptions wasn't a JSON object")
.entry("args".to_string())
.or_insert_with(|| Json::Array(vec![]))
.as_array_mut()
.expect("args wasn't a JSON array")
.extend(vec![Json::String("-headless".to_string())]);
let session_config = SpecNewSessionParameters {
always_match: cap,
first_match: vec![Capabilities::new()],
};
let request = json!({ let request = json!({
"capabilities": { "capabilities": session_config,
"alwaysMatch": {
"moz:firefoxOptions": {
"args": args,
}
}
}
}); });
let x: Response = self.post("/session", &request)?; let x: Response = self.post("/session", &request)?;
Ok(x.value.session_id) Ok(x.value.session_id)
@ -319,19 +398,26 @@ impl Client {
#[serde(rename = "sessionId")] #[serde(rename = "sessionId")]
session_id: String, session_id: String,
} }
args.push("headless".to_string()); cap.entry("goog:chromeOptions".to_string())
.or_insert_with(|| Json::Object(serde_json::Map::new()))
.as_object_mut()
.expect("goog:chromeOptions wasn't a JSON object")
.entry("args".to_string())
.or_insert_with(|| Json::Array(vec![]))
.as_array_mut()
.expect("args wasn't a JSON array")
.extend(vec![
Json::String("headless".to_string()),
// See https://stackoverflow.com/questions/50642308/ // See https://stackoverflow.com/questions/50642308/
// for what this funky `disable-dev-shm-usage` // for what this funky `disable-dev-shm-usage`
// option is // option is
args.push("disable-dev-shm-usage".to_string()); Json::String("disable-dev-shm-usage".to_string()),
args.push("no-sandbox".to_string()); Json::String("no-sandbox".to_string()),
let request = json!({ ]);
"desiredCapabilities": { let request = LegacyNewSessionParameters {
"goog:chromeOptions": { desired: cap,
"args": args, required: Capabilities::new(),
}, };
}
});
let x: Response = self.post("/session", &request)?; let x: Response = self.post("/session", &request)?;
Ok(x.session_id) Ok(x.session_id)
} }
@ -430,9 +516,9 @@ impl Client {
} }
fn doit(&mut self, path: &str, method: Method) -> Result<String, Error> { fn doit(&mut self, path: &str, method: Method) -> Result<String, Error> {
let url = format!("http://{}{}", self.driver_addr, path); let url = self.driver_url.join(path)?;
self.handle.reset(); self.handle.reset();
self.handle.url(&url)?; self.handle.url(url.as_str())?;
match method { match method {
Method::Post(data) => { Method::Post(data) => {
self.handle.post(true)?; self.handle.post(true)?;

View File

@ -46,6 +46,35 @@ test` with the appropriate browser flags and `--headless`:
wasm-pack test --headless --chrome --firefox --safari wasm-pack test --headless --chrome --firefox --safari
``` ```
## Configuring Headless Browser capabilities
Add the file `webdriver.json` to the root of your crate. Each browser has own
section for capabilities. For example:
```json
{
"moz:firefoxOptions": {
"prefs": {
"media.navigator.streams.fake": true,
"media.navigator.permission.disabled": true
},
"args": []
},
"goog:chromeOptions": {
"args": [
"--use-fake-device-for-media-stream",
"--use-fake-ui-for-media-stream"
]
}
}
```
Full list supported capabilities can be found:
* for Chrome - [here](https://peter.sh/experiments/chromium-command-line-switches/)
* for Firefox - [here](https://developer.mozilla.org/en-US/docs/Web/WebDriver/Capabilities/firefoxOptions)
Note that the `headless` argument is always enabled for both browsers.
### Debugging Headless Browser Tests ### Debugging Headless Browser Tests
Omitting the `--headless` flag will disable headless mode, and allow you to Omitting the `--headless` flag will disable headless mode, and allow you to
@ -92,6 +121,15 @@ WebDriver.
This is installed by default on Mac OS. It should be able to find your Safari This is installed by default on Mac OS. It should be able to find your Safari
installation by default. installation by default.
### Running the Tests in the Remote Headless Browser
Tests can be run on a remote webdriver. To do this, the above environment
variables must be set as URL to the remote webdriver. For example:
```
CHROMEDRIVER=http://remote.host/
```
### Running the Tests in the Headless Browser ### Running the Tests in the Headless Browser
Once the tests are configured to run in a headless browser and the appropriate Once the tests are configured to run in a headless browser and the appropriate