feat(v1): harden typed runtime boundaries
Elect one canonical EffectBatch codec, remove the parallel postcard API, and strengthen fail-closed host, form, sync, WASM, macro, generated-contract, and test-harness proofs with mutation-driven coverage. req: wire/008 req: wire/009 req: wire/010 req: push/008 req: client_local/015 req: client_local/016 req: client_local/017 req: client_local/018 req: client_local/019 req: sync/024 req: sync/025 req: sync/026 req: sync/027 req: sync/028 req: sync/029 req: test/020 req: test/021
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use hemx_core::{
|
||||
Atom, Effect, GeneratedTarget, KeyedSlot, Payload, ResourceId, ResourceKind, ResourceRef, Slot,
|
||||
Atom, Effect, Form, GeneratedTarget, KeyedSlot, NavigateMode, Payload, ResourceId,
|
||||
ResourceKind, ResourceRef, ScopeKey, Slot,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -84,6 +85,201 @@ fn finds_keyed_slot_targets() {
|
||||
assert_eq!(inspected.ops().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
|
||||
// test req: test/008 req: test/009 req: test/018
|
||||
let expected = TestTarget(ResourceKind::Slot, 42);
|
||||
let other = TestTarget(ResourceKind::Slot, 7);
|
||||
let resource = ResourceId::new(ResourceKind::Slot, 42);
|
||||
let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into()));
|
||||
let form = Form::<()>::new(11);
|
||||
let expected_emit = Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "card 42 saved".into(),
|
||||
};
|
||||
let inspected = hemx_test::inspect(vec![
|
||||
Effect::Put {
|
||||
target: ResourceRef::unscoped(other.__hemx_resource_id()),
|
||||
payload: Payload::Text("decoy needle".into()),
|
||||
},
|
||||
Effect::Put {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
payload: Payload::Text("expected text".into()),
|
||||
},
|
||||
Effect::Put {
|
||||
target: keyed_ref.clone(),
|
||||
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()),
|
||||
},
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: "row-2".into(),
|
||||
payload: Payload::Html("<li>inserted</li>".into()),
|
||||
},
|
||||
Effect::Prepend {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: "row-0".into(),
|
||||
payload: Payload::Html("<li>prepended</li>".into()),
|
||||
},
|
||||
Effect::Remove {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: Some("row-old".into()),
|
||||
},
|
||||
Effect::Focus {
|
||||
target: ResourceRef::unscoped(form.id()),
|
||||
},
|
||||
Effect::Navigate {
|
||||
url: "/cards/42".into(),
|
||||
mode: NavigateMode::Push,
|
||||
scroll: hemx_core::ScrollBehavior::Preserve,
|
||||
title: None,
|
||||
},
|
||||
expected_emit.clone(),
|
||||
Effect::Emit {
|
||||
name: "hemx:form-reset".into(),
|
||||
payload: form.id().id.to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(!inspected.is_empty());
|
||||
assert_eq!(inspected.op_count(), 10);
|
||||
assert!(inspected.contains(&expected_emit));
|
||||
assert!(!inspected.contains(&Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "wrong".into(),
|
||||
}));
|
||||
assert!(inspected.has_resource(resource));
|
||||
assert!(inspected.has_resource(other.__hemx_resource_id()));
|
||||
assert!(inspected.has_target(expected));
|
||||
assert!(inspected.has_target(other));
|
||||
assert!(!inspected.has_target(TestTarget(ResourceKind::Slot, 99)));
|
||||
assert!(inspected.updates_text(expected));
|
||||
assert!(inspected.updates_text_containing(expected, "expected"));
|
||||
assert!(!inspected.updates_text_containing(expected, "decoy"));
|
||||
assert!(inspected.updates_text(other));
|
||||
assert!(!inspected.updates_text(TestTarget(ResourceKind::Slot, 99)));
|
||||
assert!(inspected.updates_html(expected));
|
||||
assert!(inspected.updates_html_containing(expected, "replacement"));
|
||||
assert!(!inspected.updates_html_containing(expected, "missing"));
|
||||
assert!(!inspected.updates_html(TestTarget(ResourceKind::Slot, 99)));
|
||||
assert!(inspected.replaces_keyed_html_containing(expected, "row-1", "replacement"));
|
||||
assert!(!inspected.replaces_keyed_html_containing(expected, "row-2", "replacement"));
|
||||
assert!(inspected.inserts_html_containing(expected, "row-2", "inserted"));
|
||||
assert!(!inspected.inserts_html_containing(expected, "row-0", "inserted"));
|
||||
assert!(inspected.removes_key(expected, "row-old"));
|
||||
assert!(!inspected.removes_key(expected, "row-2"));
|
||||
assert!(inspected.pushes_to("/cards/42"));
|
||||
assert!(!inspected.pushes_to("/cards/7"));
|
||||
assert!(inspected.payload_contains("prepended"));
|
||||
assert!(!inspected.payload_contains("absent"));
|
||||
assert!(inspected.payload_excludes("absent"));
|
||||
assert!(!inspected.payload_excludes("expected"));
|
||||
assert!(inspected.payload_excludes_key("absent"));
|
||||
assert!(!inspected.payload_excludes_key("row-1"));
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "replacement"),
|
||||
Some("<li data-key=\"row-1\">replacement</li>")
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "inserted"),
|
||||
Some("<li>inserted</li>")
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "prepended"),
|
||||
Some("<li>prepended</li>")
|
||||
);
|
||||
assert_eq!(inspected.target_html_containing(expected, "missing"), None);
|
||||
assert!(inspected.emits("saved", "card 42 saved"));
|
||||
assert!(!inspected.emits("saved", "wrong"));
|
||||
assert!(inspected.emits_containing("saved", "42"));
|
||||
assert!(!inspected.emits_containing("other", "42"));
|
||||
assert!(inspected.has_ref(&keyed_ref));
|
||||
assert!(inspected.has_ref(&ResourceRef::unscoped(resource)));
|
||||
assert!(!inspected.has_ref(&ResourceRef::scoped(
|
||||
resource,
|
||||
ScopeKey::Field("row-1".into()),
|
||||
)));
|
||||
assert!(inspected.has_slot(Slot::<()>::new(42)));
|
||||
assert!(!inspected.has_slot(Slot::<()>::new(99)));
|
||||
assert!(inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(42)));
|
||||
assert!(!inspected.has_keyed_slot(KeyedSlot::<String, ()>::new(99)));
|
||||
assert!(!inspected.has_atom(Atom::<()>::new(42)));
|
||||
assert!(inspected.has_form(form));
|
||||
assert!(!inspected.has_form(Form::<()>::new(12)));
|
||||
assert!(inspected.resets_form(form));
|
||||
assert!(!inspected.resets_form(Form::<()>::new(12)));
|
||||
|
||||
let empty = hemx_test::inspect(Vec::<Effect>::new());
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.op_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_helpers_validate_parts_and_cover_unscoped_variants() {
|
||||
// test req: test/017
|
||||
assert_eq!(hemx_test::heading_selector("", 2), "h2");
|
||||
assert_eq!(hemx_test::list_item_selector(""), "li");
|
||||
assert_eq!(hemx_test::prose_selector(""), "p");
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 1)),
|
||||
"[data-sid=\"1\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Atom, 2)),
|
||||
"[data-aid=\"2\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Handle, 3)),
|
||||
"[data-hid=\"3\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Form, 4)),
|
||||
"[data-fid=\"4\"]"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
hemx_test::nav_link_selector("/path\\\"quoted"),
|
||||
"a[href=\"/path\\\\\\\"quoted\"]"
|
||||
);
|
||||
|
||||
hemx_test::assert_rendered_target(
|
||||
TestTarget(ResourceKind::Slot, 1),
|
||||
"slot",
|
||||
"<p data-sid=\"1\"></p>",
|
||||
);
|
||||
hemx_test::assert_rendered_handle(
|
||||
hemx_core::Handle::<()>::new(3),
|
||||
"handle",
|
||||
"<button data-hid=\"3\"></button>",
|
||||
);
|
||||
assert!(std::panic::catch_unwind(|| {
|
||||
hemx_test::assert_rendered_target(TestTarget(ResourceKind::Slot, 1), "slot", "<p></p>");
|
||||
})
|
||||
.is_err());
|
||||
assert!(std::panic::catch_unwind(|| {
|
||||
hemx_test::assert_rendered_handle(
|
||||
hemx_core::Handle::<()>::new(3),
|
||||
"handle",
|
||||
"<button data-other=\"3\"></button>",
|
||||
);
|
||||
})
|
||||
.is_err());
|
||||
|
||||
for invalid in ["", "two parts", ".class", "#id", "a>b", "a[b]"] {
|
||||
assert!(std::panic::catch_unwind(|| hemx_test::class_selector(invalid)).is_err());
|
||||
}
|
||||
for call in [
|
||||
std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")),
|
||||
std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")),
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")),
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")),
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")),
|
||||
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")),
|
||||
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")),
|
||||
] {
|
||||
assert!(call.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_generated_handle_form_bodies() {
|
||||
let handle = hemx_core::Handle::<()>::new(7);
|
||||
@@ -91,6 +287,10 @@ fn builds_generated_handle_form_bodies() {
|
||||
let body = hemx_test::handle_form_body(handle, &[("title", "hello world"), ("tag", "a&b")]);
|
||||
|
||||
assert_eq!(body, "__h=7&title=hello+world&tag=a%26b");
|
||||
assert_eq!(
|
||||
hemx_test::handle_form_body(handle, &[("AZaz09-_.~", "AZaz09-_.~ /é")],),
|
||||
"__h=7&AZaz09-_.~=AZaz09-_.~+%2F%C3%A9"
|
||||
);
|
||||
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
|
||||
}
|
||||
|
||||
@@ -189,13 +389,15 @@ fn builds_authoring_boundary_selectors() {
|
||||
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
|
||||
|
||||
let probe = hemx_test::island_probe_script(
|
||||
"probe-island",
|
||||
"orbit",
|
||||
"1|1|1|probe waiting",
|
||||
"7|2|8|probe live",
|
||||
"probe-\"island",
|
||||
"orbit\\bridge",
|
||||
"line1\nline2\rline3\tend",
|
||||
"detail \"quoted\"",
|
||||
);
|
||||
assert!(probe.contains("probe-island"));
|
||||
assert!(probe.contains("probe live"));
|
||||
assert!(probe.contains(r#"island.id = "probe-\"island";"#));
|
||||
assert!(probe.contains(r#"const islandName = "orbit\\bridge";"#));
|
||||
assert!(probe.contains(r#""line1\nline2\rline3\tend""#));
|
||||
assert!(probe.contains(r#"detail: "detail \"quoted\"""#));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use hemx_test::TestProcess;
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn process_harness_reports_early_exit_with_context() {
|
||||
@@ -27,5 +28,89 @@ fn process_harness_reports_early_exit_with_context() {
|
||||
assert!(message.contains("exited with"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_harness_waits_for_readiness_and_reaps_on_drop() {
|
||||
// test req: test/019
|
||||
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = reservation.local_addr().unwrap().to_string();
|
||||
drop(reservation);
|
||||
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("helper_process_listens")
|
||||
.arg("--nocapture")
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &addr);
|
||||
|
||||
let process = TestProcess::start(command, "listening helper", &addr, Duration::from_secs(2))
|
||||
.expect("readiness must observe the helper listener");
|
||||
assert!(TcpStream::connect(&addr).is_ok());
|
||||
drop(process);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
TcpStream::connect(&addr).is_err(),
|
||||
"drop must reap the helper"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_harness_reports_spawn_and_readiness_timeout_errors() {
|
||||
// test req: test/019
|
||||
let spawn_error = match TestProcess::start(
|
||||
Command::new("/definitely/not/a/hemx/executable"),
|
||||
"missing helper",
|
||||
"127.0.0.1:9",
|
||||
Duration::from_millis(10),
|
||||
) {
|
||||
Ok(_) => panic!("spawn failure must be returned, not panic"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(spawn_error
|
||||
.to_string()
|
||||
.contains("failed to spawn missing helper"));
|
||||
|
||||
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let unused_addr = reservation.local_addr().unwrap().to_string();
|
||||
drop(reservation);
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("helper_process_sleeps")
|
||||
.arg("--nocapture")
|
||||
.env("HEMX_TEST_PROCESS_SLEEP", "1");
|
||||
let timeout = match TestProcess::start(
|
||||
command,
|
||||
"non-listening helper",
|
||||
&unused_addr,
|
||||
Duration::from_millis(100),
|
||||
) {
|
||||
Ok(_) => panic!("non-listening process must time out"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let message = timeout.to_string();
|
||||
assert!(message.contains("timed out"), "{message}");
|
||||
assert!(message.contains("non-listening helper"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_exits_successfully() {}
|
||||
|
||||
#[test]
|
||||
fn helper_process_listens() {
|
||||
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
|
||||
return;
|
||||
};
|
||||
let _listener = TcpListener::bind(addr).expect("bind helper listener");
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_sleeps() {
|
||||
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user