From 7a44c3ee9ee98615b933736993e7dafc8b1f12fa Mon Sep 17 00:00:00 2001 From: slhx agent Date: Thu, 11 Jun 2026 23:47:55 +0200 Subject: [PATCH] test(workout): gate phone-first browser flow Add a real browser smoke for the workout exemplar that checks phone and wide viewport hierarchy, thumb-sized primary action, hidden host proof panel, and recovery interactions through hemx runtime. req: examples/001 req: local/001 req: host/002 --- Cargo.lock | 51 +++++++- examples/workout/Cargo.toml | 2 + examples/workout/tests/browser_e2e.rs | 170 ++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 examples/workout/tests/browser_e2e.rs diff --git a/Cargo.lock b/Cargo.lock index c3d6983..e9a97c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,6 +306,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -576,7 +586,7 @@ dependencies = [ "hemx-host", "hemx-test", "scraper", - "thirtyfour", + "thirtyfour 0.35.0", "tokio", ] @@ -613,6 +623,7 @@ dependencies = [ "hemx-build", "hemx-host", "hemx-test", + "thirtyfour 0.36.1", "tokio", ] @@ -1550,6 +1561,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -1706,6 +1727,33 @@ dependencies = [ "url", ] +[[package]] +name = "thirtyfour" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f1edfb88bbdf0365c6dc161e8694ce8414c0b79d0d3a3663b4baac62236be0" +dependencies = [ + "arc-swap", + "async-trait", + "base64", + "bytes", + "cfg-if", + "const_format", + "futures-util", + "http", + "indexmap", + "paste", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "stringmatch", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + [[package]] name = "thirtyfour-macros" version = "0.2.0" @@ -1792,6 +1840,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/examples/workout/Cargo.toml b/examples/workout/Cargo.toml index ab8b712..8b1643b 100644 --- a/examples/workout/Cargo.toml +++ b/examples/workout/Cargo.toml @@ -21,6 +21,8 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } [dev-dependencies] hemx-test = { path = "../../hemx-test" } +thirtyfour = { version = "0.36", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", features = ["macros", "process", "time"] } [build-dependencies] hemx-build = { path = "../../hemx-build" } diff --git a/examples/workout/tests/browser_e2e.rs b/examples/workout/tests/browser_e2e.rs new file mode 100644 index 0000000..c946cae --- /dev/null +++ b/examples/workout/tests/browser_e2e.rs @@ -0,0 +1,170 @@ +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?; + + driver + .find(By::XPath("//button[contains(., 'Skip exercise')]")) + .await? + .click() + .await?; + wait_for_body_text(&driver, "skipped Goblet squat").await?; + + driver + .find(By::XPath("//button[contains(., 'Undo last action')]")) + .await? + .click() + .await?; + wait_for_body_text(&driver, "Undid: skipped Goblet squat").await?; + + let host_panel = driver.find(By::Css(".host-proof")).await?; + assert!(!host_panel.attr("open").await?.is_some()); + + 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 primaryStyle = getComputedStyle(primary); + const gridColumns = getComputedStyle(app).gridTemplateColumns.split(' ').length; + return document.documentElement.scrollWidth <= window.innerWidth && + h1.textContent.trim().length > 0 && + primary.textContent.trim().length > 0 && + h1Rect.top < primary.getBoundingClientRect().top && + primary.getBoundingClientRect().height >= 48 && + primaryStyle.borderRadius !== '0px' && + 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(()) +}