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:
@@ -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" }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user