Files
hemx/examples/workout/tests/browser_e2e.rs
T
2026-07-13 10:24:36 +02:00

167 lines
6.4 KiB
Rust

use hemx_test::TestProcess;
use std::process::Command;
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";
const STARTUP_TIMEOUT: Duration = Duration::from_secs(8);
#[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 = TestProcess::start(app, "hemx-workout-example", APP_ADDR, STARTUP_TIMEOUT)
.expect("start ready hemx-workout-example");
let mut webdriver = Command::new("geckodriver");
webdriver.arg("--port").arg("4447");
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.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)
}
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(())
}