Files
hemx/examples/techdemo/tests/browser_e2e.rs
T
slhx agent a8d4604da8 test(techdemo): shorten generated resource paths
Import scoped generated handles and slots in techdemo tests so assertions read as local resources instead of fully-qualified generated paths.

req: component/003

req: dx/006
2026-06-01 23:03:34 +02:00

224 lines
8.0 KiB
Rust

use slhx_techdemo::ui::control_center::{handles, slots};
use slhx_techdemo::ui::issue_card::handles as card_handles;
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?;
assert_text(&driver, "Opaque island bridge").await?;
wait_for_runtime(&driver).await?;
insert_probe_island(&driver).await?;
wait_for_text(&driver, "#probe-island [data-island-readout]", "probe live").await?;
driver
.find(By::Css(&handle_selector(handles::simulate_push)))
.await?
.click()
.await?;
wait_for_text(&driver, &slot_selector(slots::notice), "Push simulated").await?;
wait_for_text(&driver, "[data-island-readout]", "activity rows").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(slots::notice), "Drag-and-drop move persisted").await?;
driver
.find(By::Css(&card_button_selector(card_handles::spotlight_work, 2)))
.await?
.click()
.await?;
wait_for_text(
&driver,
&slot_selector(slots::inspector),
"Stream typed presence · lane=Product · stage=Shipped · impact=7",
)
.await?;
driver
.find(By::Css(&card_button_selector(card_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(handles::simulate_push)))
.await?
.click()
.await?;
wait_for_text(&driver, &slot_selector(slots::live_feed), "SSE tick").await?;
driver.find(By::Css("a[href='/architecture']")).await?.click().await?;
wait_for_text(&driver, &slot_selector(slots::inspector), "Page swap").await?;
wait_for_text(&driver, "[data-island-readout]", "activity rows").await?;
assert!(driver.current_url().await?.as_str().ends_with("/architecture"));
driver.goto(&format!("http://{APP_ADDR}/")).await?;
wait_for_text(&driver, &slot_selector(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 insert_probe_island(driver: &WebDriver) -> WebDriverResult<()> {
driver
.execute(
r#"
document.querySelector('[data-slhx-root]').insertAdjacentHTML('beforeend', `
<article id="probe-island" data-slhx-island="orbit" data-island-snapshot="1|1|1|probe waiting">
<canvas width="32" height="16"></canvas>
<p data-island-readout="">waiting</p>
</article>
`);
setTimeout(() => {
document.querySelector('[data-slhx-root]').dispatchEvent(new CustomEvent('slhx:island-orbit', { bubbles: true, detail: '7|2|8|probe live' }));
}, 25);
return true;
"#,
Vec::new(),
)
.await?;
Ok(())
}
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;
}
}