062c184e42
Implement Display for generated slots, keyed slots, atoms, and forms so public examples can pass typed resources directly to hemplate dynamic attributes and tests instead of extracting raw numeric ids. req: public_api/001 req: public_api/002
351 lines
14 KiB
Rust
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),
|
|
);
|
|
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()
|
|
}
|
|
}
|