Files
hemx/examples/techdemo/tests/browser_e2e.rs
T
slhx agent 062c184e42 feat(core): format generated resources for templates
Implement Display for generated slots, keyed slots, atoms, and forms so public examples can pass typed resources directly to hemplate dynamic attributes and tests instead of extracting raw numeric ids.

req: public_api/001

req: public_api/002
2026-05-26 00:39:47 +02:00

197 lines
7.0 KiB
Rust

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)))
.await?
.click()
.await?;
wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice), "Push simulated").await?;
driver.find(By::Css("button.primary-action")).await?.click().await?;
wait_for_text(&driver, ".work-card[data-key='4']", "Ship typed effects").await?;
assert_text(&driver, "width:77%").await?;
drag_card_to_lane(&driver, 2, "product").await?;
wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='2']", "Shipped").await?;
wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice), "Drag-and-drop move persisted").await?;
driver
.find(By::Css(&card_button_selector(ui::control_center::handles::spotlight_work, 2)))
.await?
.click()
.await?;
wait_for_text(
&driver,
&slot_selector(ui::control_center::slots::inspector),
"Stream typed presence · lane=Product · stage=Shipped · impact=7",
)
.await?;
driver
.find(By::Css(&card_button_selector(ui::control_center::handles::advance_work, 3)))
.await?
.click()
.await?;
wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='3']", "Active").await?;
driver
.find(By::Css(&handle_selector(ui::control_center::handles::simulate_push)))
.await?
.click()
.await?;
wait_for_text(&driver, &slot_selector(ui::control_center::slots::live_feed), "SSE tick").await?;
driver.find(By::Css("a[href='/architecture']")).await?.click().await?;
wait_for_text(&driver, &slot_selector(ui::control_center::slots::inspector), "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), "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(handle: impl std::fmt::Display) -> String {
format!(r#"[data-hid="{handle}"]"#)
}
fn slot_selector(slot: impl std::fmt::Display) -> String {
format!(r#"[data-sid="{slot}"]"#)
}
fn card_button_selector(handle_id: impl std::fmt::Display, 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;
}
}