diff --git a/crates/cli/src/bin/wasm-bindgen-test-runner/headless.rs b/crates/cli/src/bin/wasm-bindgen-test-runner/headless.rs index 42019177..1f756a3c 100644 --- a/crates/cli/src/bin/wasm-bindgen-test-runner/headless.rs +++ b/crates/cli/src/bin/wasm-bindgen-test-runner/headless.rs @@ -2,9 +2,11 @@ use crate::shell::Shell; use curl::easy::Easy; use failure::{bail, format_err, Error, ResultExt}; use log::{debug, warn}; +use rouille::url::Url; use serde::{Deserialize, Serialize}; -use serde_json::{self, json}; +use serde_json::{self, json, Map, Value as Json}; use std::env; +use std::fs::File; use std::io::{self, Read}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::path::{Path, PathBuf}; @@ -12,6 +14,40 @@ use std::process::{Child, Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; +/// Options that can use to customize and configure a WebDriver session. +type Capabilities = Map; + +/// 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, +} + +impl Default for SpecNewSessionParameters { + fn default() -> Self { + Self { + always_match: Capabilities::new(), + first_match: vec![Capabilities::new()], + } + } +} + +fn first_match_default() -> Vec { + 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` /// address. /// @@ -20,50 +56,68 @@ use std::time::{Duration, Instant}; /// etc. It will return `Ok` if all tests finish successfully, and otherwise it /// will return an error if some tests failed. pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> { - let (driver, args, mut client_args) = Driver::find()?; - println!( - "Running headless tests in {} with `{}`", - driver.browser(), - driver.path().display() - ); + let driver = Driver::find()?; + let mut drop_log: Box = Box::new(|| ()); + let driver_url = match driver.location() { + Locate::Remote(url) => Ok(url.clone()), + Locate::Local((path, args)) => { + // 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 + // hopefully the OS gives this invocation unique ports across processes + let driver_addr = TcpListener::bind("127.0.0.1:0")?.local_addr()?; - // 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 - // hopefully the OS gives this invocation unique ports across processes - let driver_addr = TcpListener::bind("127.0.0.1:0")?.local_addr()?; + // Spawn the driver binary, collecting its stdout/stderr in separate + // threads. We'll print this output later. + let mut cmd = Command::new(path); + cmd.args(args) + .arg(format!("--port={}", driver_addr.port().to_string())); + let mut child = BackgroundChild::spawn(&path, &mut cmd, shell)?; + drop_log = Box::new(move || child.print_stdio_on_drop = false); - // Spawn the driver binary, collecting its stdout/stderr in separate - // threads. We'll print this output later. - let mut cmd = Command::new(driver.path()); - cmd.args(&args) - .arg(format!("--port={}", driver_addr.port().to_string())); - let mut child = BackgroundChild::spawn(driver.path(), &mut cmd, shell)?; - - // Wait for the driver to come online and bind its port before we try to - // connect to it. - let start = Instant::now(); - let max = Duration::new(5, 0); - let mut bound = false; - while start.elapsed() < max { - if TcpStream::connect(&driver_addr).is_ok() { - bound = true; - break; + // Wait for the driver to come online and bind its port before we try to + // connect to it. + let start = Instant::now(); + let max = Duration::new(5, 0); + let mut bound = false; + while start.elapsed() < max { + if TcpStream::connect(&driver_addr).is_ok() { + bound = true; + break; + } + thread::sleep(Duration::from_millis(100)); + } + if !bound { + bail!("driver failed to bind port during startup") + } + Url::parse(&format!("http://{}", driver_addr)).map_err(Error::from) } - thread::sleep(Duration::from_millis(100)); - } - if !bound { - bail!("driver failed to bind port during startup") - } + }?; + println!( + "Running headless tests in {} on `{}`", + driver.browser(), + driver_url.as_str(), + ); let mut client = Client { handle: Easy::new(), - driver_addr, + driver_url, 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..."); // 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`. - let id = client.new_session(&driver, &mut client_args)?; + let id = client.new_session(&driver, capabilities)?; client.session = Some(id.clone()); // 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) // 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. - child.print_stdio_on_drop = false; + drop_log(); } else { println!("failed to detect test as having been run"); if output.len() > 0 { @@ -136,48 +190,66 @@ pub fn run(server: &SocketAddr, shell: &Shell) -> Result<(), Error> { } enum Driver { - Gecko(PathBuf), - Safari(PathBuf), - Chrome(PathBuf), + Gecko(Locate), + Safari(Locate), + Chrome(Locate), +} + +enum Locate { + Local((PathBuf, Vec)), + Remote(Url), } impl Driver { - /// Attempts to find an appropriate WebDriver server binary to execute tests - /// with. Performs a number of heuristics to find one available, including: + /// Attempts to find an appropriate remote WebDriver server or server binary + /// 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. /// * Otherwise, `PATH` is searched for an appropriate binary. /// - /// In both cases a lists of auxiliary arguments is also returned which is - /// configured through env vars like `GECKODRIVER_ARGS` and - /// `GECKODRIVER_CLIENT_ARGS` to support extra arguments to invocation the - /// driver and a browser respectively. - fn find() -> Result<(Driver, Vec, Vec), Error> { - let env_vars = |name: String| { - env::var(name) + /// In the last two cases a list of auxiliary arguments is also returned + /// which is configured through env vars like `GECKODRIVER_ARGS` to support + /// extra arguments to the driver's invocation. + fn find() -> Result { + let env_args = |name: &str| { + env::var(format!("{}_ARGS", name.to_uppercase())) .unwrap_or_default() .split_whitespace() .map(|s| s.to_string()) .collect::>() }; - 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 = [ - ("geckodriver", Driver::Gecko as fn(PathBuf) -> Driver), - ("safaridriver", Driver::Safari as fn(PathBuf) -> Driver), - ("chromedriver", Driver::Chrome as fn(PathBuf) -> Driver), + ("geckodriver", Driver::Gecko as fn(Locate) -> Driver), + ("safaridriver", Driver::Safari as fn(Locate) -> Driver), + ("chromedriver", Driver::Chrome as fn(Locate) -> Driver), ]; - // First up, if env vars like GECKODRIVER are present, use those to - // allow forcing usage of a particular driver. + // First up, if env vars like GECKODRIVER_REMOTE are present, use those + // 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() { let env = driver.to_uppercase(); let path = match env::var_os(&env) { Some(path) => path, 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 @@ -188,11 +260,11 @@ impl Driver { .with_extension(env::consts::EXE_EXTENSION) .exists() }); - let (name, ctor) = match found { + let (driver, ctor) = match found { Some(p) => p, 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 @@ -200,10 +272,11 @@ impl Driver { bail!( "\ -failed to find a suitable WebDriver binary to drive headless testing; to -configure the location of the webdriver binary you can use environment -variables like `GECKODRIVER=/path/to/geckodriver` or make sure that the binary -is in `PATH` +failed to find a suitable WebDriver binary or remote running WebDriver to drive +headless testing; to configure the location of the webdriver binary you can use +environment variables like `GECKODRIVER=/path/to/geckodriver` or make sure that +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`, 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 { match self { Driver::Gecko(_) => "Firefox", @@ -238,11 +303,19 @@ an issue against rustwasm/wasm-bindgen! Driver::Chrome(_) => "Chrome", } } + + fn location(&self) -> &Locate { + match self { + Driver::Gecko(locate) => locate, + Driver::Safari(locate) => locate, + Driver::Chrome(locate) => locate, + } + } } struct Client { handle: Easy, - driver_addr: SocketAddr, + driver_url: Url, session: Option, } @@ -257,7 +330,7 @@ enum Method<'a> { // copied the `webdriver-client` crate when writing the below bindings. impl Client { - fn new_session(&mut self, driver: &Driver, args: &mut Vec) -> Result { + fn new_session(&mut self, driver: &Driver, mut cap: Capabilities) -> Result { match driver { Driver::Gecko(_) => { #[derive(Deserialize)] @@ -270,15 +343,21 @@ impl Client { #[serde(rename = "sessionId")] 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!({ - "capabilities": { - "alwaysMatch": { - "moz:firefoxOptions": { - "args": args, - } - } - } + "capabilities": session_config, }); let x: Response = self.post("/session", &request)?; Ok(x.value.session_id) @@ -319,19 +398,26 @@ impl Client { #[serde(rename = "sessionId")] session_id: String, } - args.push("headless".to_string()); - // See https://stackoverflow.com/questions/50642308/ - // for what this funky `disable-dev-shm-usage` - // option is - args.push("disable-dev-shm-usage".to_string()); - args.push("no-sandbox".to_string()); - let request = json!({ - "desiredCapabilities": { - "goog:chromeOptions": { - "args": args, - }, - } - }); + 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/ + // for what this funky `disable-dev-shm-usage` + // option is + Json::String("disable-dev-shm-usage".to_string()), + Json::String("no-sandbox".to_string()), + ]); + let request = LegacyNewSessionParameters { + desired: cap, + required: Capabilities::new(), + }; let x: Response = self.post("/session", &request)?; Ok(x.session_id) } @@ -430,9 +516,9 @@ impl Client { } fn doit(&mut self, path: &str, method: Method) -> Result { - let url = format!("http://{}{}", self.driver_addr, path); + let url = self.driver_url.join(path)?; self.handle.reset(); - self.handle.url(&url)?; + self.handle.url(url.as_str())?; match method { Method::Post(data) => { self.handle.post(true)?; diff --git a/guide/src/wasm-bindgen-test/browsers.md b/guide/src/wasm-bindgen-test/browsers.md index c9115ad8..8f952ee4 100644 --- a/guide/src/wasm-bindgen-test/browsers.md +++ b/guide/src/wasm-bindgen-test/browsers.md @@ -46,6 +46,35 @@ test` with the appropriate browser flags and `--headless`: 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 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 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 Once the tests are configured to run in a headless browser and the appropriate