use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use thirtyfour::prelude::*; const APP_ADDR: &str = "127.0.0.1:3037"; const WEBDRIVER_ADDR: &str = "127.0.0.1:4447"; struct ChildProcess { child: Child, } impl ChildProcess { fn spawn(mut command: Command) -> Self { let child = command .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn child process"); Self { child } } } impl Drop for ChildProcess { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } #[tokio::test] async fn browser_proves_phone_first_workout_flow() -> WebDriverResult<()> { // req: examples/001 req: local/001 req: host/002 let mut app = Command::new(env!("CARGO_BIN_EXE_hemx-workout-example")); app.env("HEMX_WORKOUT_ADDR", APP_ADDR); let _app = ChildProcess::spawn(app); wait_for_tcp(APP_ADDR); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg("4447"); let _webdriver = ChildProcess::spawn(webdriver); wait_for_tcp(WEBDRIVER_ADDR); let mut caps = DesiredCapabilities::firefox(); caps.set_headless()?; let driver = WebDriver::new(&format!("http://{WEBDRIVER_ADDR}"), caps).await?; let result = async { driver.set_window_rect(0, 0, 390, 844).await?; driver.goto(&format!("http://{APP_ADDR}/")).await?; wait_for_runtime(&driver).await?; assert_viewport(&driver, "phone", true).await?; driver .find(By::Css(".primary-action")) .await? .click() .await?; wait_for_body_text(&driver, "Rest 90s").await?; wait_for_body_text(&driver, "Start Goblet squat set 2").await?; wait_for_body_text(&driver, "Undo is available").await?; assert_viewport(&driver, "phone after rest", true).await?; driver .find(By::Css(".primary-action")) .await? .click() .await?; wait_for_body_text(&driver, "started Goblet squat set 2").await?; driver .find(By::XPath("//button[contains(., 'Undo last action')]")) .await? .click() .await?; wait_for_body_text(&driver, "Undid: started Goblet squat set 2").await?; let host_panel = driver.find(By::Css(".host-proof")).await?; assert!(host_panel.attr("open").await?.is_none()); driver.set_window_rect(0, 0, 1024, 900).await?; assert_viewport(&driver, "wide", false).await?; Ok::<(), WebDriverError>(()) } .await; let quit = driver.quit().await; result.and(quit) } fn wait_for_tcp(addr: &str) { let deadline = Instant::now() + Duration::from_secs(8); while Instant::now() < deadline { if std::net::TcpStream::connect(addr).is_ok() { return; } std::thread::sleep(Duration::from_millis(25)); } panic!("timed out waiting for {addr}"); } async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> { let deadline = Instant::now() + Duration::from_secs(8); loop { let loaded = driver .execute( "return !!window.hemx && window.hemx.roots().length > 0", Vec::new(), ) .await? .json() .as_bool() .unwrap_or(false); if loaded { return Ok(()); } if Instant::now() >= deadline { panic!("timed out waiting for hemx runtime"); } tokio::time::sleep(Duration::from_millis(50)).await; } } async fn wait_for_body_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> { let deadline = Instant::now() + Duration::from_secs(8); loop { let body = driver.find(By::Css("body")).await?.text().await?; if body.contains(text) { return Ok(()); } if Instant::now() >= deadline { panic!("timed out waiting for {text:?}; body={body:?}"); } tokio::time::sleep(Duration::from_millis(50)).await; } } async fn assert_viewport( driver: &WebDriver, label: &str, expect_single_column: bool, ) -> WebDriverResult<()> { let ok = driver .execute( r#" const app = document.querySelector('.workout-app'); const primary = document.querySelector('.primary-action'); const h1 = document.querySelector('h1'); const host = document.querySelector('.host-proof'); const appRect = app.getBoundingClientRect(); const h1Rect = h1.getBoundingClientRect(); const primaryRect = primary.getBoundingClientRect(); const primaryStyle = getComputedStyle(primary); const gridColumns = getComputedStyle(app).gridTemplateColumns.split(' ').length; const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); const labels = [...document.querySelectorAll('button,input,summary')] .filter(visible) .map((el) => (el.textContent || el.getAttribute('aria-label') || '').trim()); const primaryIndex = labels.findIndex((label) => label.length > 0 && label === primary.textContent.trim()); const undoIndex = labels.findIndex((label) => label.includes('Undo last action')); const hostIndex = labels.findIndex((label) => label.includes('Host proof panel')); primary.focus(); const focusedStyle = getComputedStyle(primary); const focusRingOk = document.activeElement === primary && focusedStyle.outlineStyle !== 'none' && parseFloat(focusedStyle.outlineWidth) >= 2; const zeroDurations = (value) => value.split(',').every((part) => { const trimmed = part.trim(); return trimmed === '0s' || trimmed === '0ms'; }); const noMotion = [...document.querySelectorAll('*')].every((el) => { const style = getComputedStyle(el); return zeroDurations(style.transitionDuration) && zeroDurations(style.animationDuration); }); return document.documentElement.scrollWidth <= window.innerWidth && h1.textContent.trim().length > 0 && primary.textContent.trim().length > 0 && h1Rect.top < primaryRect.top && primaryRect.height >= 48 && primaryStyle.borderRadius !== '0px' && focusRingOk && noMotion && primaryIndex === 0 && undoIndex > primaryIndex && hostIndex > undoIndex && appRect.width <= window.innerWidth && host.open === false && (arguments[0] ? gridColumns === 1 : gridColumns >= 2); "#, vec![expect_single_column.into()], ) .await? .json() .as_bool() .unwrap_or(false); assert!(ok, "{label} viewport failed product hierarchy checks"); Ok(()) }