use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; use thirtyfour::prelude::*; const WEBDRIVER_ADDR: &str = "127.0.0.1:4451"; const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); #[tokio::test] async fn client_handler_applies_effect_batch_without_network() -> WebDriverResult<()> { // req: client_local/005 req: client_local/009 req: client_local/010 // test: client_local/014 let workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .expect("workspace root") .to_owned(); build_browser_artifact(&workspace); let package = workspace.join("target/client-local-bindgen"); let runtime = workspace.join("hemx-js/runtime/hemx.js"); let server = StaticServer::start(package, runtime); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg("4451"); let _webdriver = ProcessGuard::start(webdriver, WEBDRIVER_ADDR); let mut caps = DesiredCapabilities::firefox(); caps.set_headless()?; let driver = WebDriver::new(&format!("http://{WEBDRIVER_ADDR}"), caps).await?; let result = async { driver.goto(&server.url()).await?; wait_until(&driver, "return window.__clientReady === true").await?; let network_before = resource_count(&driver).await?; driver .find(By::Css("[data-hemx-client='increment']")) .await? .click() .await?; wait_for_text( &driver, "[data-hemx-slot='counter_panel']", "updated by Rust/WASM", ) .await?; assert_eq!( resource_count(&driver).await?, network_before, "client handler made a network request" ); Ok::<(), WebDriverError>(()) } .await; let quit = driver.quit().await; result.and(quit) } fn build_browser_artifact(workspace: &Path) { let status = Command::new("cargo") .current_dir(workspace) .args([ "build", "-p", "hemx-client-local-example", "--target", "wasm32-unknown-unknown", ]) .status() .expect("run wasm cargo build"); assert!(status.success(), "WASM build failed"); let output = workspace.join("target/client-local-bindgen"); fs::create_dir_all(&output).expect("create wasm-bindgen output"); let status = Command::new("wasm-bindgen") .current_dir(workspace) .arg("--target") .arg("web") .arg("--out-name") .arg("client_local") .arg("--out-dir") .arg(&output) .arg(workspace.join("target/wasm32-unknown-unknown/debug/hemx_client_local_example.wasm")) .status() .expect("run wasm-bindgen"); assert!(status.success(), "wasm-bindgen failed"); } struct StaticServer { address: String, stop: Arc, } impl StaticServer { fn start(package: PathBuf, runtime: PathBuf) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind browser fixture"); listener.set_nonblocking(true).expect("nonblocking fixture"); let address = listener.local_addr().expect("fixture address").to_string(); let stop = Arc::new(AtomicBool::new(false)); let thread_stop = Arc::clone(&stop); thread::spawn(move || { while !thread_stop.load(Ordering::Relaxed) { match listener.accept() { Ok((stream, _)) => serve(stream, &package, &runtime), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)) } Err(error) => panic!("fixture accept failed: {error}"), } } }); Self { address, stop } } fn url(&self) -> String { format!("http://{}", self.address) } } impl Drop for StaticServer { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); } } fn serve(mut stream: TcpStream, package: &Path, runtime: &Path) { let mut request = [0_u8; 2048]; let length = stream.read(&mut request).unwrap_or(0); let first = String::from_utf8_lossy(&request[..length]); let path = first.split_whitespace().nth(1).unwrap_or("/"); let (content_type, body) = match path { "/" => ("text/html; charset=utf-8", fixture_html().into_bytes()), "/hemx.js" => ( "text/javascript; charset=utf-8", fs::read(runtime).expect("read runtime"), ), "/client_local.js" => ( "text/javascript; charset=utf-8", fs::read(package.join("client_local.js")).expect("read bindings"), ), "/client_local_bg.wasm" => ( "application/wasm", fs::read(package.join("client_local_bg.wasm")).expect("read wasm"), ), _ => ("text/plain", b"not found".to_vec()), }; let status = if path == "/" || path == "/hemx.js" || path.starts_with("/client_local") { "200 OK" } else { "404 Not Found" }; write!(stream, "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).expect("write fixture headers"); stream.write_all(&body).expect("write fixture body"); } fn fixture_html() -> String { r#"
idle
"# .to_owned() } async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { let deadline = Instant::now() + STARTUP_TIMEOUT; loop { if driver .execute(script, Vec::new()) .await? .json() .as_bool() .unwrap_or(false) { return Ok(()); } if Instant::now() >= deadline { panic!("timed out waiting for browser fixture"); } tokio::time::sleep(Duration::from_millis(50)).await; } } async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> { let deadline = Instant::now() + STARTUP_TIMEOUT; loop { if let Ok(element) = driver.find(By::Css(selector)).await { if element.text().await.unwrap_or_default().contains(text) { return Ok(()); } } if Instant::now() >= deadline { panic!("timed out waiting for {text:?}"); } tokio::time::sleep(Duration::from_millis(50)).await; } } async fn resource_count(driver: &WebDriver) -> WebDriverResult { Ok(driver .execute( "return performance.getEntriesByType('resource').length", Vec::new(), ) .await? .json() .as_u64() .unwrap_or_default()) } struct ProcessGuard(std::process::Child); impl ProcessGuard { fn start(mut command: Command, address: &str) -> Self { let child = command.spawn().expect("start geckodriver"); let deadline = Instant::now() + STARTUP_TIMEOUT; while std::net::TcpStream::connect(address).is_err() { assert!(Instant::now() < deadline, "timed out starting geckodriver"); thread::sleep(Duration::from_millis(50)); } Self(child) } } impl Drop for ProcessGuard { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } }