189bdf5eea
Give the workout exemplar a durable gym-floor command-slate direction, serve focused CSS, keep the first viewport centered on one action, and extend the HTTP E2E to verify stylesheet delivery. req: examples/001 req: local/001 req: local/003 req: host/002
198 lines
6.2 KiB
Rust
198 lines
6.2 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 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));
|
|
}
|
|
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("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("min-height: var(--tap)"));
|
|
|
|
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, "Next: Goblet squat set 2/3");
|
|
|
|
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 1 exported workout events");
|
|
assert_payload_contains(
|
|
&replayed_batch,
|
|
"Replayed 1 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)
|
|
}
|
|
}
|