Files
hemx/hemx-wasm/tests/browser.rs
T
slhx agent 38eae79a2f feat(wasm): validate client event and state ABI
req: client_local/005\nreq: client_local/006\nreq: client_local/007\nreq: client_local/008\nreq: client_local/009\nreq: client_local/010\nreq: client_local/014
2026-07-13 12:42:31 +02:00

291 lines
9.9 KiB
Rust

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 (click, count=3)",
)
.await?;
assert_eq!(
resource_count(&driver).await?,
network_before,
"client handler made a network request"
);
driver
.execute(
"document.querySelector('[data-hemx-root]').setAttribute('data-hemx-client-state-version', '2'); return true",
Vec::new(),
)
.await?;
driver
.find(By::Css("[data-hemx-client='increment']"))
.await?
.click()
.await?;
wait_until(&driver, "return window.__clientErrors.length === 1").await?;
assert!(
driver
.execute("return window.__clientErrors[0].message", Vec::new())
.await?
.json()
.as_str()
.unwrap_or_default()
.contains("unsupported client-local state ABI version 2; expected 1"),
"invalid state must produce an actionable client-local diagnostic"
);
assert_eq!(
resource_count(&driver).await?,
network_before + 1,
"declared server fallback was not requested"
);
assert!(
driver
.execute(
"return !document.querySelector('[data-hemx-client]').classList.contains('is-pending')",
Vec::new(),
)
.await?
.json()
.as_bool()
.unwrap_or(false),
"invalid input must restore pending UI"
);
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<AtomicBool>,
}
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#"<!doctype html><html><body>
<main data-hemx-root="client_local" data-hemx-st="count=3" data-hemx-client-state-version="1">
<section data-hemx-slot="counter_panel">idle</section>
<button type="button" data-hid="1" data-hemx-on="click" data-hemx-client="increment" data-hemx-client-fallback data-hemx-pending-class="is-pending">Increment locally</button>
</main>
<script src="/hemx.js"></script>
<script type="module">
import init, { __hemx_client_increment } from "/client_local.js";
await init();
const root = document.querySelector("[data-hemx-root]");
const slot = root.querySelector("[data-hemx-slot]");
const probe = __hemx_client_increment(1, "click", undefined, undefined, undefined, 1, "count=3");
const batch = window.hemx.decodeBatch(probe);
root.dataset.hemxBuild = String(batch.fingerprint);
slot.dataset.sid = String(batch.ops[0].target.resource.id);
window.hemx.registerClientHandler("increment", __hemx_client_increment);
window.__clientErrors = [];
root.addEventListener("hemx:client-error", (event) => window.__clientErrors.push(event.detail));
window.__clientReady = true;
</script></body></html>"#
.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<u64> {
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();
}
}