use slhx_techdemo::ui; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use thirtyfour::prelude::*; const APP_ADDR: &str = "127.0.0.1:3012"; const WEBDRIVER_ADDR: &str = "127.0.0.1:4445"; 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_drives_typed_product_end_to_end() -> WebDriverResult<()> { // req: examples/001 req: dx/008 req: form/002 req: page_swap/002 req: push/003 let mut app = Command::new(env!("CARGO_BIN_EXE_slhx-techdemo")); app.env("SLHX_TECHDEMO_ADDR", APP_ADDR); let _app = ChildProcess::spawn(app); wait_for_tcp(APP_ADDR); let mut webdriver = Command::new("geckodriver"); webdriver.arg("--port").arg("4445"); 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.goto(&format!("http://{APP_ADDR}/")).await?; assert_text(&driver, "A Linear-class work system without a frontend framework").await?; assert_text(&driver, "Compile checked handles").await?; assert_text(&driver, "No selectors. Generated resources address every target.").await?; wait_for_runtime(&driver).await?; driver .find(By::Css(&handle_selector(ui::control_center::handles::simulate_push.id().id))) .await? .click() .await?; wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice.id().id), "Push simulated").await?; driver .execute( r#" document.querySelector("input[name='title']").value = "Browser verified issue"; document.querySelector("input[name='impact']").value = "8"; document.querySelector("select[name='lane']").value = "product"; "#, Vec::new(), ) .await?; driver .find(By::Css(&format!( "button{}", handle_selector(ui::control_center::handles::launch_work.id().id) ))) .await? .click() .await?; wait_for_text(&driver, "body", "Browser verified issue").await?; assert_text(&driver, "width:88%").await?; drag_card_to_lane(&driver, 4, "runtime").await?; wait_for_text(&driver, ".lane[data-lane='runtime'] .work-card[data-key='4']", "Active").await?; wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice.id().id), "Drag-and-drop move persisted").await?; driver .find(By::Css(&card_button_selector(ui::control_center::handles::spotlight_work.id().id, 4))) .await? .click() .await?; wait_for_text( &driver, &slot_selector(ui::control_center::slots::inspector.id().id), "Browser verified issue · lane=Runtime · stage=Active · impact=8", ) .await?; driver .find(By::Css(&card_button_selector(ui::control_center::handles::advance_work.id().id, 4))) .await? .click() .await?; wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='4']", "Shipped").await?; driver .find(By::Css(&handle_selector(ui::control_center::handles::simulate_push.id().id))) .await? .click() .await?; wait_for_text(&driver, &slot_selector(ui::control_center::slots::live_feed.id().id), "SSE tick").await?; driver.find(By::Css("a[href='/architecture']")).await?.click().await?; wait_for_text(&driver, &slot_selector(ui::control_center::slots::inspector.id().id), "Page swap").await?; assert!(driver.current_url().await?.as_str().ends_with("/architecture")); driver.goto(&format!("http://{APP_ADDR}/")).await?; wait_for_text(&driver, &slot_selector(ui::control_center::slots::live_feed.id().id), "SSE tick").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}"); } fn handle_selector(id: u32) -> String { format!(r#"[data-hid="{id}"]"#) } fn slot_selector(id: u32) -> String { format!(r#"[data-sid="{id}"]"#) } fn card_button_selector(handle_id: u32, work_id: u64) -> String { format!(r#"[data-hid="{handle_id}"][data-work-id="{work_id}"]"#) } async fn drag_card_to_lane(driver: &WebDriver, work_id: u64, lane: &str) -> WebDriverResult<()> { driver .execute( &format!( r#" const card = document.querySelector({:?}); const lane = document.querySelector({:?}); const data = new DataTransfer(); card.dispatchEvent(new DragEvent('dragstart', {{ bubbles: true, dataTransfer: data }})); lane.dispatchEvent(new DragEvent('dragover', {{ bubbles: true, cancelable: true, dataTransfer: data }})); lane.dispatchEvent(new DragEvent('drop', {{ bubbles: true, cancelable: true, dataTransfer: data }})); return true; "#, format!(".work-card[data-key='{work_id}']"), format!(".lane[data-lane='{lane}']"), ), Vec::new(), ) .await?; Ok(()) } async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> { let deadline = Instant::now() + Duration::from_secs(8); loop { let loaded = driver .execute("return !!window.slhx && window.slhx.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 slhx runtime"); } tokio::time::sleep(Duration::from_millis(50)).await; } } async fn assert_text(driver: &WebDriver, text: &str) -> WebDriverResult<()> { wait_for_text(driver, "body", text).await } async fn wait_for_text(driver: &WebDriver, selector: &str, text: &str) -> WebDriverResult<()> { let deadline = Instant::now() + Duration::from_secs(8); let by = By::Css(selector); loop { if let Ok(element) = driver.find(by.clone()).await { let content = element.text().await.unwrap_or_default(); let html = element.inner_html().await.unwrap_or_default(); if content.contains(text) || html.contains(text) { return Ok(()); } } if Instant::now() >= deadline { let body = driver.find(By::Css("body")).await?.text().await.unwrap_or_default(); panic!("timed out waiting for {text:?} in {selector:?}; body={body:?}"); } tokio::time::sleep(Duration::from_millis(50)).await; } }