Files
hemx/examples/techdemo/tests/e2e.rs
T
slhx agent ad8806c187 feat(techdemo): render issues with hemplate partials
req: examples/001

req: dx/008

req: list/003

req: form/002
2026-05-11 07:53:51 +02:00

351 lines
14 KiB
Rust

use scraper::{Html, Selector};
use slhx::{Effect, EffectBatch, Payload, EFFECT_BATCH_ABI_VERSION};
use slhx_techdemo::ui;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const ADDR: &str = "127.0.0.1:3002";
struct Server {
child: Child,
}
impl Server {
fn start() -> Self {
let bin = env!("CARGO_BIN_EXE_slhx-techdemo");
let child = Command::new(bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("start slhx-techdemo");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if TcpStream::connect(ADDR).is_ok() {
return Self { child };
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("slhx-techdemo did not listen on {ADDR}");
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn product_is_e2e_working_over_http() {
// req: examples/001 req: dx/008 req: form/002 req: page_swap/002 req: push/003
let _server = Server::start();
let home = get("/");
assert_eq!(home.status, 200);
assert!(home.header("content-type").contains("text/html"));
let document = Html::parse_document(home.text());
assert_text(&document, "A Linear-class work system without a frontend framework");
assert_text(&document, "Compile checked handles");
assert_text(&document, "Stream typed presence");
assert_selector_count_at_least(&document, "[data-slhx-root=techdemo]", 1);
assert_selector_count_at_least(&document, "[data-hid]", 8);
assert_selector_count_at_least(&document, "[data-sid]", 7);
assert_selector_count_at_least(&document, ".work-card", 3);
assert!(home.text().contains("data-slhx-sse=\"/events\""));
let runtime = get("/slhx.js");
assert_eq!(runtime.status, 200);
assert!(runtime.header("content-type").contains("javascript"));
assert!(runtime.text().contains("const HID = \"data-hid\""));
let architecture = request("GET", "/architecture", &[("X-SLHX-Partial", "1")], "");
assert_eq!(architecture.status, 200);
assert!(architecture.header("x-slhx-partial").contains("true"));
let architecture_doc = Html::parse_document(architecture.text());
assert_text(&architecture_doc, "Page swap");
assert_text(&architecture_doc, "slhx-build");
let launch = post("/", "__h=539242093&title=Design+hero+moment&lane=product&impact=9");
assert_effect_response(&launch);
let launch_batch = launch.batch();
assert_payload_contains(&launch_batch, "Design hero moment");
assert_card(&launch_batch, "Design hero moment", "Product", "Draft", "width:99%");
assert_payload_contains(&launch_batch, "Launch accepted");
assert_payload_contains(&launch_batch, "Launched card #4");
assert!(launch_batch.ops.len() >= 5, "launch should update multiple generated targets");
let default_impact = post("/", "__h=539242093&title=Default+impact&lane=compiler");
assert_effect_response(&default_impact);
let default_impact_batch = default_impact.batch();
assert_payload_contains(&default_impact_batch, "Default impact");
assert_card(&default_impact_batch, "Default impact", "Compiler", "Draft", "width:55%");
let low_impact = post("/", "__h=539242093&title=Low+impact&lane=runtime&impact=0");
assert_effect_response(&low_impact);
let low_impact_batch = low_impact.batch();
assert_card(&low_impact_batch, "Low impact", "Runtime", "Draft", "width:11%");
let high_impact = post("/", "__h=539242093&title=High+impact&lane=runtime&impact=99");
assert_effect_response(&high_impact);
let high_impact_batch = high_impact.batch();
assert_card(&high_impact_batch, "High impact", "Runtime", "Draft", "width:99%");
let missing_title = post("/", "__h=539242093&lane=runtime&impact=8");
assert_effect_response(&missing_title);
let missing_title_batch = missing_title.batch();
assert_payload_contains(&missing_title_batch, "Launch accepted");
assert_payload_not_contains(&missing_title_batch, "data-key=\"8\"");
assert_payload_not_contains(&missing_title_batch, "MUTATED");
let move_to_lane = post(
"/",
&format!("__h={}&work_id=4&lane=runtime", ui::control_center::handles::move_to_lane.id().id),
);
assert_effect_response(&move_to_lane);
let move_to_lane_batch = move_to_lane.batch();
assert_card(&move_to_lane_batch, "Design hero moment", "Runtime", "Active", "width:99%");
assert_payload_contains(&move_to_lane_batch, "Drag-and-drop move persisted");
let inspect = post("/", "__h=3112123592&work_id=4");
assert_effect_response(&inspect);
let inspect_batch = inspect.batch();
assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime");
assert_payload_contains(&inspect_batch, "Inspector focused");
let advance = post("/", "__h=4085043678&work_id=4");
assert_effect_response(&advance);
let advance_batch = advance.batch();
assert_payload_contains(&advance_batch, "Pipeline advanced");
assert_payload_contains(&advance_batch, "<span class=\"pill\">Active</span>");
let advance_default = post("/", "__h=4085043678&work_id=5");
assert_effect_response(&advance_default);
let advance_default_batch = advance_default.batch();
assert_payload_contains(&advance_default_batch, "Default impact");
assert_card(&advance_default_batch, "Default impact", "Compiler", "Active", "width:55%");
let ship_default = post("/", "__h=4085043678&work_id=5");
assert_effect_response(&ship_default);
let ship_default_batch = ship_default.batch();
assert_card(&ship_default_batch, "Default impact", "Product", "Shipped", "width:55%");
let simulated_push = post("/", "__h=2942980687");
assert_effect_response(&simulated_push);
let push_batch = simulated_push.batch();
assert_payload_contains(&push_batch, "SSE tick");
assert_payload_contains(&push_batch, "Push simulated · no client app code");
assert_payload_contains(&push_batch, "Simulated push event produced the same EffectBatch shape");
let delete_missing = post("/", "__h=165211725&work_id=999");
assert_effect_response(&delete_missing);
let delete_missing_batch = delete_missing.batch();
assert_payload_not_contains(&delete_missing_batch, "Deleted card #999");
let delete = post("/", "__h=165211725&work_id=4");
assert_effect_response(&delete);
let delete_batch = delete.batch();
assert_payload_contains(&delete_batch, "Card removed");
assert_payload_contains(&delete_batch, "Deleted card #4");
assert_payload_not_contains(&delete_batch, "data-key=\"4\"");
assert_payload_contains(&delete_batch, "Default impact");
let reset = post("/", "__h=4166593243");
assert_effect_response(&reset);
let reset_batch = reset.batch();
assert_payload_contains(&reset_batch, "Demo reset from Rust state");
assert_payload_contains(&reset_batch, "Compile checked handles");
let sse = get("/events?once=1");
assert_eq!(sse.status, 200);
assert!(sse.header("content-type").contains("text/event-stream"));
assert!(sse.text().contains("event: slhx"));
assert!(sse.text().contains("data: "));
let unknown = post("/", "__h=999999");
assert_eq!(unknown.status, 404);
assert!(unknown.text().contains("unknown slhx handle id 999999"));
}
fn assert_effect_response(response: &Response) {
assert_eq!(response.status, 200);
assert!(response.header("content-type").contains("application/slhx"));
assert_eq!(response.header("x-slhx-fingerprint"), ui::BUILD_FINGERPRINT.0.to_string());
let batch = response.batch();
assert_eq!(batch.abi_version, EFFECT_BATCH_ABI_VERSION);
assert!(!batch.ops.is_empty());
}
fn assert_payload_contains(batch: &EffectBatch, needle: &str) {
assert!(
batch.ops.iter().any(|op| match op {
Effect::Put { payload, .. } | Effect::Insert { payload, .. } | Effect::Prepend { payload, .. } => payload_value(payload).contains(needle),
Effect::Emit { payload, .. } => payload.contains(needle),
Effect::Navigate { url, .. } => url.contains(needle),
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false,
}),
"missing payload {needle:?} in {batch:#?}"
);
}
fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact_style: &str) {
let board = board_html(batch);
let document = Html::parse_fragment(&board);
let lane_selector = Selector::parse(".lane").unwrap();
let card_selector = Selector::parse(".work-card").unwrap();
let strong_selector = Selector::parse("strong").unwrap();
let stage_selector = Selector::parse(".pill").unwrap();
let impact_selector = Selector::parse(".impact i").unwrap();
for lane_node in document.select(&lane_selector) {
let lane_text = lane_node.text().collect::<Vec<_>>().join(" ");
if !lane_text.contains(lane) {
continue;
}
for card in lane_node.select(&card_selector) {
let card_title = card
.select(&strong_selector)
.next()
.map(|node| node.text().collect::<String>())
.unwrap_or_default();
if card_title != title {
continue;
}
let card_stage = card
.select(&stage_selector)
.next()
.map(|node| node.text().collect::<String>())
.unwrap_or_default();
let style = card
.select(&impact_selector)
.next()
.and_then(|node| node.value().attr("style"))
.unwrap_or("");
assert_eq!(card_stage, stage);
assert!(style.contains(impact_style), "style {style:?} missing {impact_style:?}");
return;
}
}
panic!("missing card title={title:?} lane={lane:?} in {board}");
}
fn board_html(batch: &EffectBatch) -> String {
batch
.ops
.iter()
.find_map(|op| match op {
Effect::Put { payload: Payload::Html(value), .. } if value.contains("class=\"lanes\"") => Some(value.clone()),
_ => None,
})
.expect("board html payload")
}
fn assert_payload_not_contains(batch: &EffectBatch, needle: &str) {
assert!(
batch.ops.iter().all(|op| match op {
Effect::Put { payload, .. } | Effect::Insert { payload, .. } | Effect::Prepend { payload, .. } => !payload_value(payload).contains(needle),
Effect::Emit { payload, .. } => !payload.contains(needle),
Effect::Navigate { url, .. } => !url.contains(needle),
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => true,
}),
"unexpected payload {needle:?} in {batch:#?}"
);
}
fn payload_value(payload: &Payload) -> &str {
match payload {
Payload::Text(value) | Payload::Html(value) => value,
}
}
fn assert_text(document: &Html, text: &str) {
let body = document.root_element().text().collect::<Vec<_>>().join(" ");
assert!(body.contains(text), "missing text {text:?} in {body:?}");
}
fn assert_selector_count_at_least(document: &Html, selector: &str, expected: usize) {
let selector = Selector::parse(selector).unwrap();
let count = document.select(&selector).count();
assert!(count >= expected, "selector count {count} < {expected}");
}
fn get(path: &str) -> Response {
request("GET", path, &[], "")
}
fn post(path: &str, body: &str) -> Response {
request(
"POST",
path,
&[("Content-Type", "application/x-www-form-urlencoded")],
body,
)
}
fn request(method: &str, path: &str, headers: &[(&str, &str)], body: &str) -> Response {
let mut stream = TcpStream::connect(ADDR).expect("connect to server");
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
write!(
stream,
"{method} {path} HTTP/1.1\r\nHost: {ADDR}\r\nConnection: close\r\nContent-Length: {}\r\n",
body.len()
)
.unwrap();
for (name, value) in headers {
write!(stream, "{name}: {value}\r\n").unwrap();
}
write!(stream, "\r\n{body}").unwrap();
let mut raw = Vec::new();
stream.read_to_end(&mut raw).unwrap();
Response::parse(raw)
}
struct Response {
status: u16,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
impl Response {
fn parse(raw: Vec<u8>) -> Self {
let split = raw
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("response header terminator");
let head = String::from_utf8(raw[..split].to_vec()).unwrap();
let body = raw[split + 4..].to_vec();
let mut lines = head.lines();
let status = lines
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|status| status.parse().ok())
.expect("status code");
let headers = lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string()))
.collect();
Self { status, headers, body }
}
fn header(&self, name: &str) -> &str {
self.headers
.iter()
.find(|(key, _)| key == &name.to_ascii_lowercase())
.map(|(_, value)| value.as_str())
.unwrap_or("")
}
fn text(&self) -> &str {
std::str::from_utf8(&self.body).unwrap()
}
fn batch(&self) -> EffectBatch {
EffectBatch::from_wire(&self.body).unwrap()
}
}