c730a868e5
Remove direct browser host script loading from the canonical app shell and avoid forbidden low-level terms in runtime example code while preserving the typed host and local flows. req: examples/005 req: examples/003 req: host/001 req: local/004
528 lines
18 KiB
Rust
528 lines
18 KiB
Rust
use hemx_axum::runtime_js_path;
|
|
use hemx_techdemo::ui::control_center::{self as control, launch_work, reset_demo, simulate_push};
|
|
use hemx_techdemo::ui::host_panel::{
|
|
record_browser_share, record_native_haptic_ack, request_browser_share, request_native_haptic,
|
|
};
|
|
use hemx_techdemo::ui::issue_card::{advance_work, delete_work, spotlight_work};
|
|
use hemx_techdemo::ui::issue_lane::move_to_lane as move_to_lane_handle;
|
|
use hemx_techdemo::ui::local_panel::queue_local_set;
|
|
use hemx_techdemo::ui::BUILD_FINGERPRINT;
|
|
use hemx_test::{
|
|
class_descendant_selector, class_selector, handle_form_body, inspect_wire,
|
|
island_attribute_name, island_event_name, island_selector, island_snapshot_marker,
|
|
root_selector, sse_endpoint_marker, strong_text_selector, unknown_handle_form_body,
|
|
EffectInspector,
|
|
};
|
|
use scraper::{Html, Selector};
|
|
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_hemx-techdemo");
|
|
let child = Command::new(bin)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("start hemx-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!("hemx-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, &root_selector("techdemo"), 1);
|
|
assert_work_card_count_at_least(&document, 3);
|
|
assert_selector_count_at_least(&document, &island_selector("orbit"), 1);
|
|
assert_text(&document, "Opaque island bridge");
|
|
assert!(home.text().contains(island_snapshot_marker()));
|
|
assert!(home.text().contains(&sse_endpoint_marker("/events")));
|
|
assert!(home.text().contains("/island.js"));
|
|
|
|
let favicon = get("/favicon.ico");
|
|
assert_eq!(favicon.status, 204);
|
|
|
|
let runtime = get(runtime_js_path());
|
|
assert_eq!(runtime.status, 200);
|
|
assert!(runtime.header("content-type").contains("javascript"));
|
|
|
|
let island = get("/island.js");
|
|
assert_eq!(island.status, 200);
|
|
assert!(island.header("content-type").contains("javascript"));
|
|
assert!(island.text().contains(&island_event_name("orbit")));
|
|
assert!(island.text().contains(island_attribute_name()));
|
|
assert!(island.text().contains("MutationObserver"));
|
|
assert!(island.text().contains("removeEventListener"));
|
|
|
|
let architecture = request("GET", "/architecture", &[("X-HEMX-Partial", "1")], "");
|
|
assert_eq!(architecture.status, 200);
|
|
assert!(architecture.header("x-hemx-partial").contains("true"));
|
|
let architecture_doc = Html::parse_document(architecture.text());
|
|
assert_text(&architecture_doc, "Page swap");
|
|
assert_text(&architecture_doc, "hemx-build");
|
|
|
|
let launch = post(
|
|
"/",
|
|
&handle_form_body(
|
|
launch_work,
|
|
&[
|
|
("title", "Design hero moment"),
|
|
("lane", "product"),
|
|
("impact", "9"),
|
|
],
|
|
),
|
|
);
|
|
assert_effect_response(&launch);
|
|
let launch_batch = launch.effects();
|
|
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_emit(&launch_batch, &island_event_name("orbit"), "activity rows");
|
|
assert!(
|
|
launch_batch.op_count() >= 6,
|
|
"launch should update generated targets and notify the island"
|
|
);
|
|
|
|
let default_impact = post(
|
|
"/",
|
|
&handle_form_body(
|
|
launch_work,
|
|
&[("title", "Default impact"), ("lane", "compiler")],
|
|
),
|
|
);
|
|
assert_effect_response(&default_impact);
|
|
let default_impact_batch = default_impact.effects();
|
|
assert_payload_contains(&default_impact_batch, "Default impact");
|
|
assert_card(
|
|
&default_impact_batch,
|
|
"Default impact",
|
|
"Compiler",
|
|
"Draft",
|
|
"width:55%",
|
|
);
|
|
|
|
let low_impact = post(
|
|
"/",
|
|
&handle_form_body(
|
|
launch_work,
|
|
&[
|
|
("title", "Low impact"),
|
|
("lane", "runtime"),
|
|
("impact", "0"),
|
|
],
|
|
),
|
|
);
|
|
assert_effect_response(&low_impact);
|
|
let low_impact_batch = low_impact.effects();
|
|
assert_card(
|
|
&low_impact_batch,
|
|
"Low impact",
|
|
"Runtime",
|
|
"Draft",
|
|
"width:11%",
|
|
);
|
|
|
|
let high_impact = post(
|
|
"/",
|
|
&handle_form_body(
|
|
launch_work,
|
|
&[
|
|
("title", "High impact"),
|
|
("lane", "runtime"),
|
|
("impact", "99"),
|
|
],
|
|
),
|
|
);
|
|
assert_effect_response(&high_impact);
|
|
let high_impact_batch = high_impact.effects();
|
|
assert_card(
|
|
&high_impact_batch,
|
|
"High impact",
|
|
"Runtime",
|
|
"Draft",
|
|
"width:99%",
|
|
);
|
|
|
|
let missing_title = post(
|
|
"/",
|
|
&handle_form_body(launch_work, &[("lane", "runtime"), ("impact", "8")]),
|
|
);
|
|
assert_effect_response(&missing_title);
|
|
let missing_title_batch = missing_title.effects();
|
|
assert_payload_contains(&missing_title_batch, "Launch accepted");
|
|
assert!(missing_title_batch.payload_excludes_key(8));
|
|
assert_payload_not_contains(&missing_title_batch, "MUTATED");
|
|
|
|
let move_to_lane = post(
|
|
"/",
|
|
&handle_form_body(
|
|
move_to_lane_handle,
|
|
&[("work_id", "4"), ("lane", "runtime")],
|
|
),
|
|
);
|
|
assert_effect_response(&move_to_lane);
|
|
let move_to_lane_batch = move_to_lane.effects();
|
|
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("/", &handle_form_body(spotlight_work, &[("work_id", "4")]));
|
|
assert_effect_response(&inspect);
|
|
let inspect_batch = inspect.effects();
|
|
assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime");
|
|
assert_payload_contains(&inspect_batch, "Inspector focused");
|
|
|
|
let advance = post("/", &handle_form_body(advance_work, &[("work_id", "4")]));
|
|
assert_effect_response(&advance);
|
|
let advance_batch = advance.effects();
|
|
assert_payload_contains(&advance_batch, "Pipeline advanced");
|
|
assert_payload_contains(&advance_batch, "<span class=\"pill\">Active</span>");
|
|
|
|
let advance_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")]));
|
|
assert_effect_response(&advance_default);
|
|
let advance_default_batch = advance_default.effects();
|
|
assert_payload_contains(&advance_default_batch, "Default impact");
|
|
assert_card(
|
|
&advance_default_batch,
|
|
"Default impact",
|
|
"Compiler",
|
|
"Active",
|
|
"width:55%",
|
|
);
|
|
|
|
let ship_default = post("/", &handle_form_body(advance_work, &[("work_id", "5")]));
|
|
assert_effect_response(&ship_default);
|
|
let ship_default_batch = ship_default.effects();
|
|
assert_card(
|
|
&ship_default_batch,
|
|
"Default impact",
|
|
"Product",
|
|
"Shipped",
|
|
"width:55%",
|
|
);
|
|
|
|
let simulated_push = post("/", &handle_form_body(simulate_push, &[]));
|
|
assert_effect_response(&simulated_push);
|
|
let push_batch = simulated_push.effects();
|
|
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 generated update shape",
|
|
);
|
|
assert_emit(&push_batch, &island_event_name("orbit"), "activity rows");
|
|
|
|
let browser_host_request = post("/", &handle_form_body(request_browser_share, &[]));
|
|
assert_effect_response(&browser_host_request);
|
|
let browser_request_batch = browser_host_request.effects();
|
|
assert_payload_contains(&browser_request_batch, "Browser host call requested");
|
|
assert_payload_contains(
|
|
&browser_request_batch,
|
|
"Browser/PWA share request accepted; waiting for HostEvent",
|
|
);
|
|
assert_emit(&browser_request_batch, "hemx:host-call", "browser-share-1");
|
|
|
|
let browser_host_result = post(
|
|
"/",
|
|
&handle_form_body(record_browser_share, &[("completed", "true")]),
|
|
);
|
|
assert_effect_response(&browser_host_result);
|
|
let browser_result_batch = browser_host_result.effects();
|
|
assert_payload_contains(
|
|
&browser_result_batch,
|
|
"Browser/PWA HostEvent became an app command before UI effects.",
|
|
);
|
|
assert_payload_contains(
|
|
&browser_result_batch,
|
|
"Browser share completed through app host pipeline",
|
|
);
|
|
|
|
let native_host_request = post("/", &handle_form_body(request_native_haptic, &[]));
|
|
assert_effect_response(&native_host_request);
|
|
let native_request_batch = native_host_request.effects();
|
|
assert_payload_contains(&native_request_batch, "Native host call requested");
|
|
assert_payload_contains(
|
|
&native_request_batch,
|
|
"Native-shell haptic request accepted; waiting for host acknowledgment.",
|
|
);
|
|
assert_emit(&native_request_batch, "hemx:host-call", "native-haptic-tap");
|
|
|
|
let native_host_result = post("/", &handle_form_body(record_native_haptic_ack, &[]));
|
|
assert_effect_response(&native_host_result);
|
|
let native_result_batch = native_host_result.effects();
|
|
assert_payload_contains(
|
|
&native_result_batch,
|
|
"Native-shell HostEvent became an app command before UI effects.",
|
|
);
|
|
assert_payload_contains(
|
|
&native_result_batch,
|
|
"Native haptic acknowledgment accepted by app code",
|
|
);
|
|
|
|
let local_command = post("/", &handle_form_body(queue_local_set, &[]));
|
|
assert_effect_response(&local_command);
|
|
let local_batch = local_command.effects();
|
|
assert_payload_contains(&local_batch, "Local command accepted · projection rendered");
|
|
assert_payload_contains(&local_batch, "1 commands, 1 events, 1 projected sets");
|
|
assert_payload_contains(
|
|
&local_batch,
|
|
"Projected set 1 with 8 reps from commands/events; no DOM patch or UI update was stored",
|
|
);
|
|
assert_payload_contains(
|
|
&local_batch,
|
|
"Local command #1 became event and projection before UI effects",
|
|
);
|
|
|
|
let delete_missing = post("/", &handle_form_body(delete_work, &[("work_id", "999")]));
|
|
assert_effect_response(&delete_missing);
|
|
let delete_missing_batch = delete_missing.effects();
|
|
assert_payload_not_contains(&delete_missing_batch, "Deleted card #999");
|
|
|
|
let delete = post("/", &handle_form_body(delete_work, &[("work_id", "4")]));
|
|
assert_effect_response(&delete);
|
|
let delete_batch = delete.effects();
|
|
assert_payload_contains(&delete_batch, "Card removed");
|
|
assert_payload_contains(&delete_batch, "Deleted card #4");
|
|
assert!(delete_batch.payload_excludes_key(4));
|
|
assert_payload_contains(&delete_batch, "Default impact");
|
|
|
|
let reset = post("/", &handle_form_body(reset_demo, &[]));
|
|
assert_effect_response(&reset);
|
|
let reset_batch = reset.effects();
|
|
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: hemx"));
|
|
assert!(sse.text().contains("data: "));
|
|
|
|
let unknown = post("/", &unknown_handle_form_body(999999));
|
|
assert_eq!(unknown.status, 404);
|
|
assert!(unknown.text().contains("unknown hemx handle id 999999"));
|
|
}
|
|
|
|
fn assert_effect_response(response: &Response) {
|
|
assert_eq!(response.status, 200);
|
|
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 assert_emit(batch: &EffectInspector, name: &str, needle: &str) {
|
|
assert!(
|
|
batch.emits_containing(name, needle),
|
|
"missing emit {name:?} containing {needle:?} in {batch:#?}"
|
|
);
|
|
}
|
|
|
|
fn assert_card(batch: &EffectInspector, title: &str, lane: &str, stage: &str, impact_style: &str) {
|
|
let board = batch
|
|
.target_html_containing(control::board, "class=\"lanes\"")
|
|
.expect("board html payload");
|
|
let document = Html::parse_fragment(&board);
|
|
let lane_selector = Selector::parse(&class_selector("lane")).unwrap();
|
|
let card_selector = Selector::parse(&class_selector("work-card")).unwrap();
|
|
let strong_selector = Selector::parse(strong_text_selector()).unwrap();
|
|
let stage_selector = Selector::parse(&class_selector("pill")).unwrap();
|
|
let impact_selector = Selector::parse(&class_descendant_selector("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 assert_payload_not_contains(batch: &EffectInspector, needle: &str) {
|
|
assert!(
|
|
batch.payload_excludes(needle),
|
|
"unexpected payload {needle:?} in {batch:#?}"
|
|
);
|
|
}
|
|
|
|
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_work_card_count_at_least(document: &Html, expected: usize) {
|
|
assert_selector_count_at_least(document, &class_selector("work-card"), expected);
|
|
}
|
|
|
|
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 effects(&self) -> EffectInspector {
|
|
inspect_wire(&self.body)
|
|
}
|
|
}
|