feat(wasm): execute client handlers without requests
req: client_local/001\nreq: client_local/004\nreq: client_local/005\nreq: client_local/009\nreq: client_local/010\nreq: client_local/014\nreq: v1_release/001
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "hemx-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
hemx-core = { path = "../hemx-core" }
|
||||
wasm-bindgen = "=0.2.125"
|
||||
|
||||
[dev-dependencies]
|
||||
thirtyfour = "0.36"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Opt-in browser boundary for client-local hemx handlers.
|
||||
//!
|
||||
//! Application code reaches this crate through the `hemx` `client` feature and
|
||||
//! `#[hemx::handler(client)]`; server-first applications do not depend on it.
|
||||
|
||||
use hemx_core::{BuildFingerprint, IntoEffect};
|
||||
|
||||
#[doc(hidden)]
|
||||
pub use wasm_bindgen::prelude::wasm_bindgen;
|
||||
#[doc(hidden)]
|
||||
pub use wasm_bindgen::*;
|
||||
|
||||
/// Encodes a client handler result with the ordinary hemx effect wire format.
|
||||
///
|
||||
/// Keeping this conversion here gives generated WASM exports one ABI boundary
|
||||
/// instead of teaching the proc macro a second effect protocol.
|
||||
#[doc(hidden)]
|
||||
pub fn encode_handler_effect(effect: impl IntoEffect, fingerprint: BuildFingerprint) -> Vec<u8> {
|
||||
effect.into_batch(fingerprint).to_wire()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::encode_handler_effect;
|
||||
use hemx_core::{BuildFingerprint, EffectBatch, Slot};
|
||||
|
||||
#[test]
|
||||
fn client_handler_uses_the_ordinary_effect_wire_format() {
|
||||
let fingerprint = BuildFingerprint(17);
|
||||
let effect = Slot::<()>::new(4).text("local");
|
||||
let wire = encode_handler_effect(effect.clone(), fingerprint);
|
||||
|
||||
assert_eq!(
|
||||
EffectBatch::from_wire(&wire).expect("decode client effect"),
|
||||
EffectBatch {
|
||||
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
|
||||
fingerprint,
|
||||
ops: vec![effect],
|
||||
}
|
||||
); // req: client_local/005 req: client_local/009
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
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<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">
|
||||
<section data-hemx-slot="counter_panel">idle</section>
|
||||
<button type="button" data-hid="1" data-hemx-client="increment">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();
|
||||
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.__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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user