use hemx_test::TestProcess; use std::net::TcpListener; use std::process::Command; use std::time::Duration; use thirtyfour::prelude::*; const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); #[tokio::test] async fn server_first_route_does_not_load_optional_client_assets() -> WebDriverResult<()> { // test req: performance/006 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); app.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start ready hemx-kanban"); let webdriver_port = available_port(); let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg(webdriver_port.to_string()); let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) .expect("start ready geckodriver"); let mut caps = DesiredCapabilities::firefox(); caps.set_headless()?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let result = async { driver.goto(&format!("http://{app_addr}/")).await?; driver.find(By::Css("section[data-hemx-root='kanban']")).await?; let loaded = driver .execute_async( r#" const done = arguments[arguments.length - 1]; navigator.serviceWorker.getRegistrations().then(async (registrations) => { const databases = indexedDB.databases ? await indexedDB.databases() : []; done({ resources: performance.getEntriesByType('resource').map((entry) => new URL(entry.name).pathname), scripts: [...document.scripts].map((script) => ({ src: new URL(script.src).pathname, type: script.type })), clientRoots: document.querySelectorAll('[data-hemx-client-module]').length, serviceWorkers: registrations.length, databases: databases.map((database) => database.name), }); }).catch((error) => done({ error: String(error) })); "#, Vec::new(), ) .await? .json() .clone(); assert!(loaded["error"].is_null(), "browser inspection failed: {loaded}"); assert_eq!(loaded["clientRoots"], 0); assert_eq!(loaded["serviceWorkers"], 0); assert_eq!(loaded["databases"].as_array().map(Vec::len), Some(0)); let scripts = loaded["scripts"].as_array().expect("document scripts"); assert_eq!(scripts.len(), 1); let runtime_path = scripts[0]["src"].as_str().expect("runtime script path"); assert!( runtime_path.starts_with("/hemx.") && runtime_path.ends_with(".js"), "unexpected server runtime asset: {loaded}" ); assert_eq!(scripts[0]["type"], ""); let resources = loaded["resources"] .as_array() .expect("resource timing entries") .iter() .filter_map(|value| value.as_str()) .collect::>(); assert!(resources.contains(&runtime_path), "runtime was not loaded: {loaded}"); for optional in [ "/hemx.client.js", "/kanban_client.js", "/kanban_client_bg.wasm", "/app.js", "/offline.js", ] { assert!( !resources.contains(&optional), "server-first route loaded optional client asset {optional}: {loaded}" ); } Ok(()) } .await; let quit = driver.quit().await; result.and(quit) } #[tokio::test] async fn idempotent_server_command_is_acknowledged_after_reconnect() -> WebDriverResult<()> { // test req: sync/001 req: sync/005 req: sync/006 req: sync/007 // test req: sync/008 req: sync/012 req: sync/013 let app_port = available_port(); let app_addr = format!("127.0.0.1:{app_port}"); let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-kanban-example")); app.env("HEMX_KANBAN_ADDR", &app_addr); let _app = TestProcess::start(app, "hemx-kanban", &app_addr, STARTUP_TIMEOUT) .expect("start ready hemx-kanban"); let webdriver_port = available_port(); let webdriver_addr = format!("127.0.0.1:{webdriver_port}"); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg(webdriver_port.to_string()); let _webdriver = TestProcess::start(webdriver, "geckodriver", &webdriver_addr, STARTUP_TIMEOUT) .expect("start ready geckodriver"); let mut caps = DesiredCapabilities::firefox(); caps.set_headless()?; let driver = WebDriver::new(&format!("http://{webdriver_addr}"), caps).await?; let result = async { driver.goto(&format!("http://{app_addr}/")).await?; driver.find(By::Css("section[data-hemx-root='kanban']")).await?; driver .execute( r#" window.__syncProof = { ready: false, opens: 0, events: [], error: null }; (async () => { const endpoint = '/sync/commands?command_id=actor-1%3A1&card_id=1'; const firstResponse = await fetch(endpoint, { method: 'POST' }); const first = await firstResponse.json(); const duplicateResponse = await fetch(endpoint, { method: 'POST' }); const duplicate = await duplicateResponse.json(); const conflictResponse = await fetch('/sync/commands?command_id=actor-1%3A1&card_id=2', { method: 'POST' }); const conflict = await conflictResponse.json(); window.__syncProof.command = { firstStatus: firstResponse.status, duplicateStatus: duplicateResponse.status, first, duplicate, conflictStatus: conflictResponse.status, conflict, }; const source = new EventSource('/sync/acknowledgements?after=0&reconnect=browser-proof'); source.onopen = () => { window.__syncProof.opens += 1; }; source.addEventListener('acknowledgement', (event) => { window.__syncProof.events.push({ id: event.lastEventId, acknowledgement: JSON.parse(event.data) }); window.__syncProof.ready = true; source.close(); }); source.onerror = () => { if (source.readyState === EventSource.CLOSED && !window.__syncProof.ready) { window.__syncProof.error = 'acknowledgement stream closed'; } }; })().catch((error) => { window.__syncProof.error = String(error); }); return true; "#, Vec::new(), ) .await?; wait_until( &driver, "return window.__syncProof.ready === true || window.__syncProof.error !== null", ) .await?; let proof = driver .execute("return window.__syncProof", Vec::new()) .await? .json() .clone(); assert!(proof["error"].is_null(), "sync failed: {proof}"); assert!( proof["opens"].as_u64().is_some_and(|opens| opens >= 2), "transport did not reconnect: {proof}" ); assert_eq!(proof["command"]["firstStatus"], 200); assert_eq!(proof["command"]["duplicateStatus"], 200); assert_eq!(proof["command"]["first"], proof["command"]["duplicate"]); assert_eq!(proof["command"]["first"]["commandId"], "actor-1:1"); assert_eq!(proof["command"]["first"]["serverSequence"], 1); assert_eq!(proof["command"]["first"]["cardId"], 1); assert_eq!(proof["command"]["first"]["canonicalColumn"], "done"); assert_eq!(proof["command"]["first"]["status"], "accepted"); assert_eq!(proof["command"]["conflictStatus"], 409); assert_eq!( proof["command"]["conflict"]["error"], "command_id was already used for a different payload" ); assert_eq!(proof["events"].as_array().map(Vec::len), Some(1)); assert_eq!(proof["events"][0]["id"], "1"); assert_eq!( proof["events"][0]["acknowledgement"], proof["command"]["first"] ); driver.refresh().await?; let canonical = driver .execute( "return [...document.querySelectorAll('section.column')].map((column) => ({ title: column.querySelector('h2').textContent, cards: [...column.querySelectorAll('[data-key]')].map((card) => card.dataset.key) }))", Vec::new(), ) .await? .json() .clone(); assert_eq!(canonical[2]["title"], "Done"); assert_eq!(canonical[2]["cards"], serde_json::json!(["1", "3"])); Ok(()) } .await; let quit = driver.quit().await; result.and(quit) } async fn wait_until(driver: &WebDriver, script: &str) -> WebDriverResult<()> { for _ in 0..200 { if driver.execute(script, Vec::new()).await?.json().as_bool() == Some(true) { return Ok(()); } tokio::time::sleep(Duration::from_millis(25)).await; } panic!("browser condition timed out: {script}"); } fn available_port() -> u16 { TcpListener::bind("127.0.0.1:0") .expect("reserve browser test port") .local_addr() .expect("browser test address") .port() }