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
This commit is contained in:
slhx agent
2026-06-11 23:47:55 +02:00
parent 58b0c70709
commit 7a44c3ee9e
3 changed files with 222 additions and 1 deletions
Generated
+50 -1
View File
@@ -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",
+2
View File
@@ -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" }
+170
View File
@@ -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(())
}