use super::shell::Shell;
use anyhow::{bail, Context, Error};
use log::{debug, warn};
use rouille::url::Url;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value as Json};
use std::env;
use std::fs::File;
use std::io::{self, Cursor, ErrorKind, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use ureq::Agent;
type Capabilities = Map<String, Json>;
#[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()]
}
#[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,
}
pub fn run(
server: &SocketAddr,
shell: &Shell,
driver_timeout: u64,
test_timeout: u64,
nocapture: bool,
) -> Result<(), Error> {
let driver = Driver::find()?;
let mut drop_log: Box<dyn FnMut()> = Box::new(|| ());
let driver_url = match driver.location() {
Locate::Remote(url) => Ok(url.clone()),
Locate::Local((path, args)) => {
let start = Instant::now();
let max = Duration::new(driver_timeout, 0);
let (driver_addr, mut child) = 'outer: loop {
let driver_addr = TcpListener::bind("127.0.0.1:0")?.local_addr()?;
let mut cmd = Command::new(path);
cmd.args(args).arg(format!("--port={}", driver_addr.port()));
let mut child = BackgroundChild::spawn(path, &mut cmd, shell)?;
loop {
if child.has_failed() {
if start.elapsed() >= max {
bail!("driver failed to start")
}
println!("Failed to start driver, trying again ...");
thread::sleep(Duration::from_millis(100));
break;
} else if TcpStream::connect(driver_addr).is_ok() {
break 'outer (driver_addr, child);
} else if start.elapsed() >= max {
bail!("driver failed to bind port during startup")
} else {
thread::sleep(Duration::from_millis(100));
}
}
};
drop_log = Box::new(move || {
let _ = &child;
child.print_stdio_on_drop = false;
});
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 {
agent: Agent::new_with_defaults(),
driver_url,
session: None,
};
println!("Try find `webdriver.json` for configure browser's capabilities:");
let capabilities: Capabilities = match File::open(
std::env::var("WASM_BINDGEN_TEST_WEBDRIVER_JSON").unwrap_or("webdriver.json".to_string()),
) {
Ok(file) => {
println!("Ok");
serde_json::from_reader(file)
}
Err(_) => {
println!("Not found");
Ok(Capabilities::new())
}
}?;
shell.status("Starting new webdriver session...");
let id = client.new_session(&driver, capabilities)?;
client.session = Some(id.clone());
let browser_name = client
.session_browser_name(&id)
.unwrap_or_else(|| driver.browser().to_ascii_lowercase());
let style_mode = style_mode_for_browser(&browser_name);
let mut url = match std::env::var("WASM_BINDGEN_TEST_ADDRESS") {
Ok(addr) => {
let mut url = Url::parse(&format!("http://{addr}"))?;
if url.port().is_none() {
url.set_port(Some(server.port())).unwrap();
}
url
}
Err(_) => Url::parse(&format!("http://{server}"))?,
};
let style = match style_mode {
StyleMode::DisplayNone => "display-none",
StyleMode::VisibilityHidden => "visibility-hidden",
};
url.set_fragment(Some(&format!("wbg_style={style}")));
shell.status(&format!(
"Visiting {url} (browser: {browser_name}, sink: append, style: {style_mode:?}, poll: 100ms)..."
));
client.goto(&id, url.as_str())?;
shell.status("Loading page elements...");
shell.status("Waiting for test to finish...");
let start = Instant::now();
let max = Duration::new(test_timeout, 0);
let no_stream_scrape = env::var_os("WASM_BINDGEN_TEST_NO_STREAM").is_some();
let mut shell_cleared = false;
let mut output_buf = String::new();
let mut output_offset = 0usize;
while start.elapsed() < max {
if no_stream_scrape {
let output = client.text_content(&id, "#output", 0)?;
if output.chunk.contains("test result: ") {
output_buf = output.chunk;
output_offset = output.next_offset;
break;
}
} else {
let output = client.text_content(&id, "#output", output_offset)?;
let new_output = output.chunk;
output_offset = output.next_offset;
if !new_output.is_empty() {
if !shell_cleared {
shell.clear();
shell_cleared = true;
}
io::stdout().lock().write_all(new_output.as_bytes())?;
output_buf.push_str(&new_output);
}
if output_buf.contains("test result: ") {
break;
}
}
thread::sleep(Duration::from_millis(100));
}
if !shell_cleared {
shell.clear();
}
if no_stream_scrape && !output_buf.is_empty() {
io::stdout().lock().write_all(output_buf.as_bytes())?;
}
let remaining_output = {
let output = client.text_content(&id, "#output", output_offset)?;
output.chunk
};
if !remaining_output.is_empty() {
io::stdout().lock().write_all(remaining_output.as_bytes())?;
output_buf.push_str(&remaining_output);
}
if output_buf.contains("test result: ") {
drop_log();
} else {
println!("Failed to detect test as having been run. It might have timed out.");
}
if nocapture && output_buf.contains("test result: ok") {
let console_output = client.text_content(&id, "#console_output", 0)?;
if !console_output.chunk.is_empty() {
bail!(
"with --nocapture, #console_output should be empty but contained:\n{}",
console_output.chunk
);
}
}
if !output_buf.contains("test result: ok") {
let mut has_output = false;
let mut offset = 0;
loop {
let output = client.text_content(&id, "#console_output", offset)?;
let chunk = output.chunk;
if chunk.is_empty() {
break;
}
if !has_output {
println!("console output:");
has_output = true;
}
io::stdout().lock().write_all(tab(&chunk).as_bytes())?;
offset = output.next_offset;
}
bail!("some tests failed")
}
Ok(())
}
#[derive(Copy, Clone, Debug)]
enum StyleMode {
DisplayNone,
VisibilityHidden,
}
fn style_mode_for_browser(browser_name: &str) -> StyleMode {
let browser = browser_name.to_ascii_lowercase();
if browser.contains("safari") {
StyleMode::VisibilityHidden
} else {
StyleMode::DisplayNone
}
}
enum Driver {
Gecko(Locate),
Safari(Locate),
Chrome(Locate),
Edge(Locate),
}
enum Locate {
Local((PathBuf, Vec<String>)),
Remote(Url),
}
impl Driver {
fn find() -> Result<Driver, Error> {
let env_args = |name: &str| {
let var = env::var(format!("{}_ARGS", name.to_uppercase())).unwrap_or_default();
shlex::split(&var)
.unwrap_or_else(|| var.split_whitespace().map(|s| s.to_string()).collect())
};
let drivers = [
("geckodriver", Driver::Gecko as fn(Locate) -> Driver),
("safaridriver", Driver::Safari as fn(Locate) -> Driver),
("chromedriver", Driver::Chrome as fn(Locate) -> Driver),
("msedgedriver", Driver::Edge as fn(Locate) -> Driver),
];
for (driver, ctor) in drivers.iter() {
let env = format!("{}_REMOTE", driver.to_uppercase());
let url = match env::var(&env) {
Ok(var) => Url::parse(&var).context(format!("failed to parse `{env}`"))?,
Err(_) => continue,
};
return Ok(ctor(Locate::Remote(url)));
}
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(Locate::Local((path.into(), env_args(driver)))));
}
for path in env::split_paths(&env::var_os("PATH").unwrap_or_default()) {
let found = drivers.iter().find(|(name, _)| {
path.join(name)
.with_extension(env::consts::EXE_EXTENSION)
.exists()
});
let (driver, ctor) = match found {
Some(p) => p,
None => continue,
};
return Ok(ctor(Locate::Local((driver.into(), env_args(driver)))));
}
bail!(
"\
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`, `safaridriver`, and
`msedgedriver`, although more driver support may be added! You can download these at:
* geckodriver - https://github.com/mozilla/geckodriver/releases
* chromedriver - https://chromedriver.chromium.org/downloads
* msedgedriver - https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/
* safaridriver - should be preinstalled on OSX
If you would prefer to not use headless testing and would instead like to do
interactive testing in a web browser then you can specify `NO_HEADLESS=1` as
an environment variable. When rerun the tests will start a server that you can
visit in a web browser, and headless testing should not be used.
If you're still having difficulty resolving this error, please feel free to open
an issue against wasm-bindgen/wasm-bindgen!
"
)
}
fn browser(&self) -> &str {
match self {
Driver::Gecko(_) => "Firefox",
Driver::Safari(_) => "Safari",
Driver::Chrome(_) => "Chrome",
Driver::Edge(_) => "Edge",
}
}
fn location(&self) -> &Locate {
match self {
Driver::Gecko(locate) => locate,
Driver::Safari(locate) => locate,
Driver::Chrome(locate) => locate,
Driver::Edge(locate) => locate,
}
}
}
struct Client {
agent: Agent,
driver_url: Url,
session: Option<String>,
}
enum Method<'a> {
Get,
Post(&'a str),
Delete,
}
impl Client {
fn new_session(&mut self, driver: &Driver, mut cap: Capabilities) -> Result<String, Error> {
match driver {
Driver::Gecko(_) => {
#[derive(Deserialize)]
struct Response {
value: ResponseValue,
}
#[derive(Deserialize)]
struct ResponseValue {
#[serde(rename = "sessionId")]
session_id: 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": session_config,
});
let x: Response = self.post("/session", &request)?;
Ok(x.value.session_id)
}
Driver::Safari(_) => {
#[derive(Clone, Deserialize)]
struct Response {
#[serde(rename = "sessionId")]
session_id: Option<String>,
value: Option<Value>,
}
#[derive(Clone, Deserialize)]
struct Value {
#[serde(rename = "sessionId")]
session_id: Option<String>,
}
let request = json!({
"desiredCapabilities": {
},
"capabilities": {
}
});
let x: Response = self.post("/session", &request)?;
Ok(x.clone()
.session_id
.or_else(|| x.value.map(|v| v.session_id.unwrap()))
.unwrap())
}
Driver::Chrome(_) => {
#[derive(Deserialize)]
struct Response {
#[serde(rename = "sessionId")]
session_id: 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()),
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)
}
Driver::Edge(_) => {
#[derive(Deserialize)]
struct Response {
#[serde(rename = "sessionId")]
session_id: String,
}
cap.entry("ms:edgeOptions".to_string())
.or_insert_with(|| Json::Object(serde_json::Map::new()))
.as_object_mut()
.expect("ms:edgeOptions 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()),
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)
}
}
}
fn close_window(&mut self, id: &str) -> Result<(), Error> {
#[derive(Deserialize)]
struct Response {}
let _: Response = self.delete(&format!("/session/{id}/window"))?;
Ok(())
}
fn goto(&mut self, id: &str, url: &str) -> Result<(), Error> {
#[derive(Serialize)]
struct Request {
url: String,
}
#[derive(Deserialize)]
struct Response {}
let request = Request {
url: url.to_string(),
};
let _: Response = self.post(&format!("/session/{id}/url"), &request)?;
Ok(())
}
fn text_content(
&mut self,
id: &str,
selector: &str,
offset: usize,
) -> Result<TextChunk, Error> {
#[derive(Serialize)]
struct Request {
script: String,
args: Vec<usize>,
}
#[derive(Deserialize)]
struct Response {
value: serde_json::Value,
}
#[derive(Deserialize)]
struct Value {
chunk: String,
next_offset: usize,
}
let request = Request {
script: format!(
"const el = document.querySelector({}); \
if (!el || el.textContent == null) {{ \
return {{ chunk: \"\", next_offset: arguments[0] }}; \
}} \
const text = el.textContent; \
const start = Math.min(arguments[0], text.length); \
return {{ chunk: text.slice(start), next_offset: text.length }};",
serde_json::to_string(selector)?
),
args: vec![offset],
};
let x: Response = self.post(&format!("/session/{id}/execute/sync"), &request)?;
match x.value {
serde_json::Value::Object(_) => {
let value: Value = serde_json::from_value(x.value)?;
Ok(TextChunk {
chunk: value.chunk,
next_offset: value.next_offset,
})
}
serde_json::Value::Null => Ok(TextChunk {
chunk: String::new(),
next_offset: offset,
}),
other => bail!("unexpected response from execute/sync: {other:?}"),
}
}
fn session_browser_name(&mut self, id: &str) -> Option<String> {
let value: serde_json::Value = match self.get(&format!("/session/{id}")) {
Ok(value) => value,
Err(err) => {
debug!("failed to read webdriver session capabilities: {err:#}");
return None;
}
};
value
.get("value")
.and_then(|v| {
v.get("capabilities")
.and_then(|caps| caps.get("browserName"))
.or_else(|| v.get("browserName"))
})
.or_else(|| {
value
.get("capabilities")
.and_then(|caps| caps.get("browserName"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn get<U>(&mut self, path: &str) -> Result<U, Error>
where
U: for<'a> Deserialize<'a>,
{
debug!("GET {path}");
let result = self.doit(path, Method::Get)?;
Ok(serde_json::from_str(&result)?)
}
fn post<T, U>(&mut self, path: &str, data: &T) -> Result<U, Error>
where
T: Serialize,
U: for<'a> Deserialize<'a>,
{
let input = serde_json::to_string(data)?;
debug!("POST {path} {input}");
let result = self.doit(path, Method::Post(&input))?;
Ok(serde_json::from_str(&result)?)
}
fn delete<U>(&mut self, path: &str) -> Result<U, Error>
where
U: for<'a> Deserialize<'a>,
{
debug!("DELETE {path}");
let result = self.doit(path, Method::Delete)?;
Ok(serde_json::from_str(&result)?)
}
fn doit(&mut self, path: &str, method: Method) -> Result<String, Error> {
let url = self.driver_url.join(path)?;
let mut response = match method {
Method::Post(data) => self
.agent
.post(url.as_str())
.content_type("application/json")
.send(data.as_bytes())?,
Method::Get => self.agent.get(url.as_str()).call()?,
Method::Delete => self.agent.delete(url.as_str()).call()?,
};
let response_code = response.status();
let result = response.body_mut().read_to_string()?;
if response_code != 200 {
bail!("non-200 response code: {response_code}\n{result}");
}
debug!("got: {result}");
Ok(result)
}
}
impl Drop for Client {
fn drop(&mut self) {
let id = match &self.session {
Some(id) => id.clone(),
None => return,
};
if let Err(e) = self.close_window(&id) {
warn!("failed to close window {e:?}");
}
}
}
struct TextChunk {
chunk: String,
next_offset: usize,
}
fn tab(s: &str) -> String {
let mut result = String::new();
for line in s.lines() {
result.push_str(" ");
result.push_str(line);
result.push('\n');
}
result
}
struct BackgroundChild<'a> {
child: Child,
stdout: Option<thread::JoinHandle<io::Result<Vec<u8>>>>,
stderr: Option<thread::JoinHandle<io::Result<Vec<u8>>>>,
any_stderr: Arc<AtomicBool>,
shell: &'a Shell,
print_stdio_on_drop: bool,
}
impl<'a> BackgroundChild<'a> {
fn spawn(
path: &Path,
cmd: &mut Command,
shell: &'a Shell,
) -> Result<BackgroundChild<'a>, Error> {
cmd.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null());
log::debug!("executing {cmd:?}");
let mut child = cmd
.spawn()
.context(format!("failed to spawn {path:?} binary"))?;
let mut stdout = child.stdout.take().unwrap();
let mut stderr = child.stderr.take().unwrap();
let stdout = Some(thread::spawn(move || {
let mut dst = Vec::new();
stdout.read_to_end(&mut dst)?;
Ok(dst)
}));
let any_stderr = Arc::new(AtomicBool::new(false));
let any_stderr_clone = Arc::clone(&any_stderr);
let stderr = Some(thread::spawn(move || {
let mut dst = Cursor::new(Vec::new());
let mut buffer = [0];
match stderr.read_exact(&mut buffer) {
Ok(()) => {
dst.write_all(&buffer).unwrap();
any_stderr_clone.store(true, Ordering::Relaxed);
}
Err(error) if error.kind() == ErrorKind::UnexpectedEof => {
return Ok(dst.into_inner())
}
Err(error) => return Err(error),
}
io::copy(&mut stderr, &mut dst)?;
Ok(dst.into_inner())
}));
Ok(BackgroundChild {
child,
stdout,
stderr,
any_stderr,
shell,
print_stdio_on_drop: true,
})
}
fn has_failed(&mut self) -> bool {
match self.child.try_wait() {
Ok(Some(status)) => !status.success(),
Ok(None) => self.any_stderr.load(Ordering::Relaxed),
Err(_) => true,
}
}
}
impl Drop for BackgroundChild<'_> {
fn drop(&mut self) {
self.child.kill().unwrap();
let status = self.child.wait().unwrap();
if !self.print_stdio_on_drop {
return;
}
self.shell.clear();
println!("driver status: {status}");
let stdout = self.stdout.take().unwrap().join().unwrap().unwrap();
if !stdout.is_empty() {
println!("driver stdout:\n{}", tab(&String::from_utf8_lossy(&stdout)));
}
let stderr = self.stderr.take().unwrap().join().unwrap().unwrap();
if !stderr.is_empty() {
println!("driver stderr:\n{}", tab(&String::from_utf8_lossy(&stderr)));
}
}
}