test(techdemo): add typed browser e2e
req: examples/001 req: dx/008 req: form/002 req: page_swap/002 req: push/003
This commit is contained in:
Generated
+1040
-8
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"]
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
slhx-test = { path = "../../slhx-test" }
|
||||
thirtyfour = "0.35"
|
||||
|
||||
[build-dependencies]
|
||||
slhx-build = { path = "../../slhx-build" }
|
||||
|
||||
@@ -21,4 +21,5 @@ This is a polished Linear-style product demo for planning typed work across lane
|
||||
Verification:
|
||||
|
||||
cargo test -p slhx-techdemo --test e2e
|
||||
mutest -p slhx-techdemo -f examples/techdemo/src/main.rs -F 'registry' --test-package slhx-techdemo -j 2 --timeout 90
|
||||
cargo test -p slhx-techdemo --test browser_e2e
|
||||
mutest -p slhx-techdemo -f examples/techdemo/src/main.rs -F 'registry' -j 2 --timeout 90 -- --test e2e
|
||||
|
||||
@@ -101,7 +101,10 @@ async fn main() {
|
||||
.route("/slhx.js", get(runtime))
|
||||
.with_state(state);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3002));
|
||||
let addr = std::env::var("SLHX_TECHDEMO_ADDR")
|
||||
.ok()
|
||||
.and_then(|addr| addr.parse().ok())
|
||||
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 3002)));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
println!("slhx techdemo: http://{addr}");
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
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, "Rust owns the interaction graph").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
|
||||
.execute(
|
||||
&format!(
|
||||
r#"
|
||||
return fetch("/", {{
|
||||
method: "POST",
|
||||
headers: {{ "Content-Type": "application/x-www-form-urlencoded" }},
|
||||
body: "__h={}&title=Browser+verified+issue&lane=product&impact=8"
|
||||
}})
|
||||
.then((response) => response.arrayBuffer())
|
||||
.then((buffer) => {{ window.slhx.applyBatch(buffer, document.querySelector("[data-slhx-root]")); return true; }});
|
||||
"#,
|
||||
ui::control_center::handles::launch_work.id().id
|
||||
),
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice.id().id), "Launch accepted").await?;
|
||||
assert_text(&driver, "Browser verified issue").await?;
|
||||
assert_text(&driver, "width:88%").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=Product · stage=Draft · 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, ".work-card[data-key='4']", "Active").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 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user