Files
hemx/examples/workout/tests/e2e.rs
T
2026-07-13 08:59:48 +02:00

229 lines
7.6 KiB
Rust

use hemx_axum::runtime_js_path;
use hemx_test::{inspect_wire, EffectInspector};
use hemx_workout_example::ui::BUILD_FINGERPRINT;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
struct Server {
child: Child,
addr: String,
}
impl Server {
fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port");
let addr = listener.local_addr().unwrap().to_string();
drop(listener);
let bin = env!("CARGO_BIN_EXE_hemx-workout-example");
let mut child = Command::new(bin)
.env("HEMX_WORKOUT_ADDR", &addr)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("start hemx-workout-example");
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if TcpStream::connect(&addr).is_ok() {
return Self { child, addr };
}
std::thread::sleep(Duration::from_millis(25));
}
let _ = child.kill();
let _ = child.wait();
panic!("hemx-workout-example did not listen on {addr}");
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn workout_is_e2e_working_over_http() {
// req: examples/001 req: local/001 req: local/003 req: local/004 req: host/001
let server = Server::start();
let home = get(&server, "/");
assert_eq!(home.status, 200);
assert!(home.header("content-type").contains("text/html"));
assert!(home.text().contains("Now-first Workout Copilot"));
assert!(home.text().contains("Finish & recover"));
assert!(home.text().contains("Undo last action"));
assert!(home.text().contains("Fix last set"));
assert!(home.text().contains("Private event log"));
assert!(home.text().contains("Replay export"));
assert!(home
.text()
.contains("<link rel=\"stylesheet\" href=\"/workout.css\""));
assert!(home
.text()
.contains(&format!("<script src=\"{}\" defer", runtime_js_path())));
let css = get(&server, "/workout.css");
assert_eq!(css.status, 200);
assert!(css.header("content-type").contains("text/css"));
assert!(css.text().contains(".command-slate"));
assert!(css.text().contains(".finish-card"));
assert!(css.text().contains("min-height: var(--tap)"));
assert!(css.text().contains("prefers-reduced-motion: reduce"));
assert!(css.text().contains("animation-duration: 0ms"));
let runtime = get(&server, runtime_js_path());
assert_eq!(runtime.status, 200);
assert!(runtime.header("content-type").contains("javascript"));
assert!(!runtime.text().is_empty());
let completed = post(
&server,
"/",
&handle_body_from_html(&home.text(), "Complete set"),
);
assert_effect_response(&completed);
let completed_batch = completed.effects();
assert_payload_contains(&completed_batch, "completed Goblet squat set 1");
assert_payload_contains(&completed_batch, "Rest 90s · next: Goblet squat set 2");
assert_payload_contains(&completed_batch, "Start Goblet squat set 2");
assert_payload_contains(&completed_batch, "1 of 5 sets · 20% complete");
let complete_body = handle_body_from_html(&home.text(), "Complete set");
let mut final_step = completed;
for _ in 0..9 {
final_step = post(&server, "/", &complete_body);
assert_effect_response(&final_step);
}
let final_batch = final_step.effects();
assert_payload_contains(&final_batch, "finished workout: 5/5 sets complete");
assert_payload_contains(&final_batch, "Workout saved");
assert_payload_contains(&final_batch, "Share final export");
let exported = post(
&server,
"/",
&handle_body_from_html(&home.text(), "Export after first set"),
);
assert_effect_response(&exported);
let exported_batch = exported.effects();
assert_payload_contains(&exported_batch, "Share export prepared");
assert_payload_contains(&exported_batch, "Share sheet requested");
let replayed = post(
&server,
"/",
&handle_body_from_html(&home.text(), "Replay export"),
);
assert_effect_response(&replayed);
let replayed_batch = replayed.effects();
assert_payload_contains(&replayed_batch, "Replayed 10 exported workout events");
assert_payload_contains(
&replayed_batch,
"Replayed 10 exported events into a fresh projection",
);
}
fn handle_body_from_html(html: &str, label: &str) -> String {
format!("__h={}", handle_id_from_html(html, label))
}
fn handle_id_from_html(html: &str, label: &str) -> String {
let label_at = html.find(label).expect("label in html");
let prefix = &html[..label_at];
let hid_at = prefix.rfind("data-hid=\"").expect("handle before label") + "data-hid=\"".len();
let end = prefix[hid_at..].find('"').expect("handle quote");
prefix[hid_at..hid_at + end].to_owned()
}
fn assert_effect_response(response: &Response) {
assert_eq!(response.status, 200, "response: {response:?}");
assert!(response.header("content-type").contains("application/hemx"));
assert_eq!(
response.header("x-hemx-fingerprint"),
BUILD_FINGERPRINT.0.to_string()
);
assert!(!response.effects().is_empty());
}
fn assert_payload_contains(batch: &EffectInspector, needle: &str) {
assert!(
batch.payload_contains(needle),
"missing payload {needle:?} in {batch:#?}"
);
}
fn get(server: &Server, path: &str) -> Response {
request(server, "GET", path, "")
}
fn post(server: &Server, path: &str, body: &str) -> Response {
request(server, "POST", path, body)
}
fn request(server: &Server, method: &str, path: &str, body: &str) -> Response {
let mut stream = TcpStream::connect(&server.addr).expect("connect workout example");
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\n\r\n{body}",
server.addr,
body.len()
);
stream.write_all(request.as_bytes()).unwrap();
let mut raw = Vec::new();
stream.read_to_end(&mut raw).unwrap();
Response::parse(raw)
}
#[derive(Debug)]
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("http response");
let head = String::from_utf8(raw[..split].to_vec()).expect("utf8 headers");
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.to_ascii_lowercase(), value.trim().to_owned()))
.collect();
Self {
status,
headers,
body,
}
}
fn header(&self, name: &str) -> String {
let name = name.to_ascii_lowercase();
self.headers
.iter()
.find_map(|(key, value)| (key == &name).then(|| value.clone()))
.unwrap_or_default()
}
fn text(&self) -> String {
String::from_utf8(self.body.clone()).expect("utf8 body")
}
fn effects(&self) -> EffectInspector {
inspect_wire(&self.body)
}
}