Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+182
View File
@@ -0,0 +1,182 @@
#![cfg(feature = "axum")]
use axum::body::Body;
use axum::http::{header, HeaderMap, HeaderValue, Method, Response, StatusCode};
use axum::response::{Html, IntoResponse, Redirect};
use axum::routing::{get, post};
use axum::Router;
use hemx_axum::{EffectResponse, InteractionForm, HEMX_CONTENT_TYPE};
use hemx_core::{BuildFingerprint, Handle, Slot};
async fn effects() -> EffectResponse {
EffectResponse::new(
Slot::<()>::new(1).text("ready"),
BuildFingerprint::from_parts(&[7]),
)
}
async fn document() -> Html<&'static str> {
Html("<!doctype html><html><body><main id=app>Ready</main></body></html>")
}
async fn fragment() -> Html<&'static str> {
Html("<li data-state=open>Write tests</li>")
}
async fn redirect() -> Redirect {
Redirect::to("/next")
}
async fn rejected() -> impl IntoResponse {
(StatusCode::UNPROCESSABLE_ENTITY, "invalid title")
}
async fn request_header(headers: HeaderMap) -> String {
headers
.get("x-test")
.and_then(|value| value.to_str().ok())
.unwrap_or("missing")
.to_owned()
}
async fn malformed_wire() -> Response<Body> {
Response::builder()
.header(header::CONTENT_TYPE, HEMX_CONTENT_TYPE)
.body(Body::from(vec![0xff, 0x00, 0x01]))
.unwrap()
}
async fn form(form: InteractionForm) -> String {
format!("{}:{}", form.handle_id, form.value("title").unwrap_or(""))
}
fn router() -> Router {
Router::new()
.route("/effects", get(effects))
.route("/document", get(document))
.route("/fragment", get(fragment))
.route("/redirect", get(redirect))
.route("/rejected", get(rejected))
.route("/request-header", get(request_header))
.route("/malformed", get(malformed_wire))
.route("/form", post(form))
}
#[tokio::test]
async fn inspects_effect_response_from_real_router() {
let response = hemx_test::axum::get("/effects")
.send(router())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.content_type(), Some(HEMX_CONTENT_TYPE));
assert!(!response.body().is_empty());
assert!(response
.effects()
.unwrap()
.updates_text_containing(GeneratedSlot, "ready"));
}
#[tokio::test]
async fn preserves_status_headers_redirects_and_text_errors() {
let request_header = hemx_test::axum::request(Method::GET, "/request-header")
.header(
"x-test".parse().unwrap(),
HeaderValue::from_static("request"),
)
.send(router())
.await
.unwrap();
assert_eq!(request_header.text().unwrap(), "request");
let redirect = hemx_test::axum::get("/redirect")
.send(router())
.await
.unwrap();
assert_eq!(redirect.status(), StatusCode::SEE_OTHER);
assert_eq!(redirect.headers()[header::LOCATION], "/next");
assert!(redirect.body().is_empty());
let rejected = hemx_test::axum::get("/rejected")
.send(router())
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(rejected.text().unwrap(), "invalid title");
}
#[tokio::test]
async fn structurally_inspects_document_and_fragment_responses() {
let document = hemx_test::axum::get("/document")
.send(router())
.await
.unwrap()
.html_document()
.unwrap();
document.assert_text("main#app", "Ready");
let fragment = hemx_test::axum::get("/fragment")
.send(router())
.await
.unwrap()
.html_fragment()
.unwrap();
fragment.assert_attribute("li", "data-state", "open");
fragment.assert_text("li", "Write tests");
}
#[tokio::test]
async fn builds_urlencoded_interaction_forms_from_typed_handles() {
let response = hemx_test::axum::post("/form")
.form(Handle::<()>::new(17), &[("title", "one & two")])
.send(router())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().unwrap(), "17:one & two");
}
#[tokio::test]
async fn rejects_unexpected_content_types_and_malformed_wire() {
let html = hemx_test::axum::get("/document")
.send(router())
.await
.unwrap();
let wrong_type = html.effects().unwrap_err().to_string();
assert!(wrong_type.contains("application/hemx"), "{wrong_type}");
assert!(wrong_type.contains("text/html"), "{wrong_type}");
let malformed = hemx_test::axum::get("/malformed")
.send(router())
.await
.unwrap()
.effects()
.unwrap_err()
.to_string();
assert!(
malformed.contains("invalid Hemx effect response"),
"{malformed}"
);
}
#[tokio::test]
async fn enforces_explicit_response_body_limit() {
let error = hemx_test::axum::get("/document")
.body_limit(8)
.send(router())
.await
.unwrap_err()
.to_string();
assert!(error.contains("within 8 bytes"), "{error}");
}
#[derive(Clone, Copy)]
struct GeneratedSlot;
impl hemx_core::GeneratedTarget for GeneratedSlot {
fn __hemx_resource_id(self) -> hemx_core::ResourceId {
Slot::<()>::new(1).id()
}
}
-388
View File
@@ -1,388 +0,0 @@
use std::path::Path;
#[test]
fn canonical_examples_do_not_author_browser_javascript() {
// req: examples/005
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.components().any(|part| part.as_os_str() == "tests") {
return;
}
for (line_no, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.contains("<script") && !allowed_example_script(path, trimmed) {
failures.push(format!(
"{}:{}: only the hemx runtime or explicit opaque-island scripts are allowed",
path.display(),
line_no + 1
));
}
if contains_inline_event_handler(trimmed) {
failures.push(format!(
"{}:{}: inline on*= handler is not allowed",
path.display(),
line_no + 1
));
}
if trimmed.to_ascii_lowercase().contains("javascript:") {
failures.push(format!(
"{}:{}: javascript: URL is not allowed",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn canonical_examples_do_not_author_low_level_resource_plumbing() {
// req: dx/002 req: ceremony/002 req: ceremony/004 req: examples/003
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let forbidden = [
"ResourceId::new",
".id().id",
"include!(concat!",
"OUT_DIR",
"global_exports(",
"EffectWriter",
"RuntimeOpcode",
"data-hid",
"data-sid",
"data-fid",
"data-aid",
"data-handle-id",
"data-slot-id",
"data-form-id",
"data-atom-id",
"::lower(include_str!",
"lower_html(",
"render_html(",
"HandlerRegistry",
".into_registry()",
"InteractionForm",
"register_handle(",
"SafeHtml",
"hemx::advanced",
"advanced::slots",
"KeyedSlot",
"Slot<",
"Effect::",
"Payload::",
"NavigateMode",
"ScopeKey",
"opcode",
"wire format",
"manual generated",
"RenderSlotExt",
"RenderKeyedSlotExt",
".render(&",
".html(ui::render",
".html(hemx::render",
".html(super::ui::render",
"ui::put(",
"ui::append(",
"ui::replace(",
"application/hemx",
"EffectBatch",
"__h=",
"name=\"__h\"",
"name='__h'",
];
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.components().any(|part| part.as_os_str() == "tests") {
return;
}
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
return;
};
if !matches!(ext, "rs" | "heml" | "html") {
return;
}
let runtime_source = if ext == "rs" {
text.split("#[cfg(test)]").next().unwrap_or(text)
} else {
text
};
for (line_no, line) in runtime_source.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!(
"{}:{}: example runtime code must use generated resources, not `{token}`",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn public_prelude_does_not_export_low_level_primitives() {
// req: public_api/005 req: dx/006
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let facade = std::fs::read_to_string(root.join("hemx/src/lib.rs")).unwrap();
let prelude = facade
.split("pub mod prelude {")
.nth(1)
.and_then(|tail| tail.split("\n}").next())
.expect("hemx facade exposes prelude module");
let exported = prelude
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
for token in [
"SafeHtml",
"Effect",
"Slot",
"KeyedSlot",
"ResourceId",
"EffectBatch",
"BuildFingerprint",
"Atom",
"ComponentRef",
"EventName",
"GeneratedTarget",
"Handle",
"ParamName",
"page",
] {
assert!(
!exported.contains(&token),
"hemx::prelude must not export low-level `{token}`"
);
}
assert!(prelude.contains("Html"), "hemx::prelude should export Html");
assert!(
prelude.contains("IntoEffect"),
"hemx::prelude should export IntoEffect"
);
}
#[test]
fn canonical_example_docs_do_not_teach_low_level_plumbing() {
// req: dx/002 req: dx/003 req: examples/003
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let forbidden = [
"generated handles and slots",
"generated hemx handles",
"generated handle ids",
"numeric handle ids",
"runtime lowering",
"application/hemx responses",
"EffectBatch",
"manual registry",
"HandlerRegistry",
".into_registry()",
"InteractionForm",
"register_handle(",
"lower_html(",
"render_html(",
"SafeHtml",
"KeyedSlot",
"Slot<",
"Effect::",
"opcode",
"wire format",
"manual generated",
"Effect::batch",
"Effect::class",
"Effect::move",
"Effect::set",
"Effect::broadcast",
"Effect::ack",
"SyncEffect::",
"postcard DOM ops",
"data-hid",
"data-sid",
".render(&",
"addEventListener(",
"querySelector",
"querySelectorAll",
];
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.extension().and_then(|ext| ext.to_str()) != Some("md")
|| is_advanced_boundary_doc(text)
{
return;
}
for (line_no, line) in text.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!(
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn example_docs_do_not_show_runtime_metadata_as_authoring_contract() {
// req: dx/006 req: misc/006
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let examples = root.join("examples");
let forbidden = [
"data-hid",
"data-sid",
"data-aid",
"data-fid",
"[data-hid",
"[data-sid",
];
let mut failures = Vec::new();
scan_examples(&examples, &mut |path, text| {
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
return;
}
for (line_no, line) in text.lines().enumerate() {
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
failures.push(format!(
"{}:{}: example docs must describe generated authoring APIs, not runtime metadata `{token}`",
path.display(),
line_no + 1
));
}
}
});
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn browser_e2e_does_not_shortcut_product_interactions() {
// req: examples/005
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let browser_e2e = root.join("examples/techdemo/tests/browser_e2e.rs");
let source = std::fs::read_to_string(browser_e2e).unwrap();
assert!(
!source.contains("fetch(\"/\""),
"browser E2E must drive UI, not post directly"
);
assert!(
!source.contains("applyBatch(buffer"),
"browser E2E must not apply wire batches manually"
);
}
#[test]
fn html_examples_slot_targets_dispatch_to_component_put() {
// req: runtime/005 req: examples/001 req: htmx_equivalents/001
// Regression: slots named after a component (e.g. contact_card, editable_row)
// must lower injected partials using that component's handle table, not the
// containing page's handle table.
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let template_dir = root.join("examples/html_examples/templates");
let out_dir = std::env::temp_dir().join(format!(
"hemx-test-html-examples-slot-targets-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&out_dir);
std::fs::create_dir_all(&out_dir).unwrap();
let result = std::panic::catch_unwind(|| {
hemx_build::app()
.template_dir(&template_dir)
.out_dir(&out_dir)
.run()
.expect("hemx-build should generate html_examples artifacts");
let generated =
std::fs::read_to_string(out_dir.join("hemx.generated.rs")).unwrap_or_default();
for component in ["contact_card", "editable_row"] {
let component_path = format!("super::super::{component}");
let impl_header = format!("impl<T> SlotTarget<T, {component_path}::Component>");
let put_call = format!("{component_path}::put(self.slot, view)");
assert!(
generated.contains(&impl_header),
"gallery::targets should have a component-specific SlotTarget impl for `{component}`; missing `{impl_header}`"
);
assert!(
generated.contains(&put_call),
"gallery::targets::{component} put/replace should dispatch to `{component_path}::put`; missing `{put_call}`"
);
}
});
let _ = std::fs::remove_dir_all(&out_dir);
result.unwrap();
}
fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
scan_examples(&path, visit);
continue;
}
if !is_example_source(&path) {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
visit(&path, &text);
}
}
fn is_advanced_boundary_doc(text: &str) -> bool {
text.contains("advanced/low-level north-star boundary sketch")
}
fn is_example_source(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some("rs" | "heml" | "html" | "md")
)
}
fn allowed_example_script(path: &Path, line: &str) -> bool {
// req: examples/005
line.contains(r#"<script +src="self.runtime_src" defer></script>"#)
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|| (path.ends_with("examples/saas/templates/app_shell.heml")
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
|| (path.ends_with("examples/kanban/templates/legacy_sync_fixture.heml")
&& line.contains(r#"<script type="module" src="/sync.js"></script>"#))
}
fn contains_inline_event_handler(line: &str) -> bool {
let bytes = line.as_bytes();
let mut i = 0;
while i + 3 < bytes.len() {
let boundary = bytes[i].is_ascii_whitespace() || bytes[i] == b'<';
if boundary
&& bytes[i + 1] == b'o'
&& bytes[i + 2] == b'n'
&& bytes[i + 3].is_ascii_lowercase()
{
let mut j = i + 4;
while j < bytes.len() && bytes[j].is_ascii_lowercase() {
j += 1;
}
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if j < bytes.len() && bytes[j] == b'=' {
return true;
}
}
i += 1;
}
false
}
+82
View File
@@ -0,0 +1,82 @@
use hemx_core::{Effect, GeneratedTarget, ResourceId, ResourceKind, Slot};
use std::future::Future;
use std::task::{Context, Poll, Waker};
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let mut context = Context::from_waker(Waker::noop());
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::yield_now(),
}
}
}
#[derive(Clone, Copy)]
struct CountTarget;
impl GeneratedTarget for CountTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(ResourceKind::Slot, 1)
}
}
fn sync_handler(value: u32) -> Effect {
Slot::<u32>::new(1).text(value)
}
async fn async_handler(value: u32) -> Effect {
std::future::ready(()).await;
Slot::<u32>::new(1).text(value)
}
#[derive(Debug, Eq, PartialEq)]
struct Rejected {
value: u32,
}
fn fallible_handler(value: u32) -> Result<Effect, Rejected> {
if value == 0 {
Err(Rejected { value })
} else {
Ok(sync_handler(value))
}
}
async fn fallible_async_handler(value: u32) -> Result<Effect, Rejected> {
std::future::ready(()).await;
if value == 0 {
Err(Rejected { value })
} else {
Ok(sync_handler(value))
}
}
#[test]
fn runs_sync_and_async_handlers_into_the_same_effect_inspector() {
let sync = hemx_test::run(sync_handler, 41);
assert!(sync.updates_text_containing(CountTarget, "41"));
let asynchronous = block_on(hemx_test::run_async(async_handler, 42));
assert!(asynchronous.updates_text_containing(CountTarget, "42"));
}
#[test]
fn inspects_successful_fallible_handlers() {
let sync = hemx_test::run_result(fallible_handler, 41).unwrap();
assert!(sync.updates_text_containing(CountTarget, "41"));
let asynchronous = block_on(hemx_test::run_async_result(fallible_async_handler, 42)).unwrap();
assert!(asynchronous.updates_text_containing(CountTarget, "42"));
}
#[test]
fn preserves_concrete_sync_and_async_handler_errors() {
let sync = hemx_test::run_result(fallible_handler, 0).unwrap_err();
assert_eq!(sync, Rejected { value: 0 });
let asynchronous =
block_on(hemx_test::run_async_result(fallible_async_handler, 0)).unwrap_err();
assert_eq!(asynchronous, Rejected { value: 0 });
}
+211
View File
@@ -0,0 +1,211 @@
use hemx_core::{
Effect, GeneratedTarget, InsertPosition, PatchMode, ResourceId, ResourceKind, ResourceRef,
SafeHtml,
};
fn panic_text<T>(result: std::thread::Result<T>) -> String {
let panic = match result {
Ok(_) => panic!("operation must panic"),
Err(panic) => panic,
};
if let Some(message) = panic.downcast_ref::<String>() {
message.clone()
} else if let Some(message) = panic.downcast_ref::<&str>() {
(*message).to_owned()
} else {
panic!("panic payload was not text")
}
}
#[test]
fn inspects_complete_documents_with_owned_structure() {
let inspected = hemx_test::inspect_html_document(
r#"<!doctype html>
<html lang="en">
<head><title>Todos &amp; notes</title></head>
<body><main><h1> Todos today </h1><p data-state="ready">two &lt; three</p></main></body>
</html>"#,
);
inspected.assert_count("html", 1);
inspected.assert_text("title", "Todos & notes");
inspected.assert_text("main > h1", "Todos today");
inspected.assert_attribute("p[data-state]", "data-state", "ready");
let paragraph = inspected.select("main p").unwrap();
assert_eq!(paragraph.elements()[0].name(), "p");
assert_eq!(paragraph.elements()[0].text(), "two < three");
assert_eq!(
paragraph.elements()[0].attribute("data-state"),
Some("ready")
);
assert!(paragraph.elements()[0]
.attributes()
.any(|attribute| attribute == ("data-state", "ready")));
}
#[test]
fn inspects_generated_targets_and_handles_without_raw_runtime_ids() {
let target = TestTarget(ResourceKind::Slot, 42);
let handle = hemx_core::Handle::<()>::new(7);
let inspected = hemx_test::inspect_html_fragment(
r#"<section data-sid="42"><button data-hid="7">Save</button></section>"#,
);
inspected.assert_target(target);
inspected.assert_handle(handle);
assert_eq!(
inspected.select_target(target).elements()[0].name(),
"section"
);
assert_eq!(inspected.select_handle(handle).elements()[0].text(), "Save");
}
#[test]
fn inspects_fragments_and_keeps_selections_owned() {
let selection = {
let inspected = hemx_test::inspect_html_fragment(
r#"<ul><li class="todo">one</li><li class="todo">two</li></ul>"#,
);
inspected.assert_exists("ul > li.todo");
inspected.assert_count("ul > li.todo", 2);
inspected.select("li.todo").unwrap()
};
assert_eq!(selection.selector(), "li.todo");
assert_eq!(selection.len(), 2);
assert!(!selection.is_empty());
assert_eq!(selection.elements()[0].text(), "one");
assert_eq!(selection.elements()[1].text(), "two");
}
#[test]
fn inspects_generated_target_document_and_fragment_payloads() {
let document_target = TestTarget(ResourceKind::Slot, 1);
let document_effect = Effect::Patch {
target: ResourceRef::unscoped(document_target.__hemx_resource_id()),
mode: PatchMode::Morph,
html: SafeHtml::trusted(
"<!doctype html><html><body><main id=app>ready</main></body></html>",
),
};
let document = hemx_test::inspect(document_effect)
.target_html_document(document_target)
.unwrap();
document.assert_text("main#app", "ready");
assert!(document.origin().contains("Patch HTML effect"));
for (effect, operation) in [
(
Effect::Insert {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)),
position: InsertPosition::Last,
html: SafeHtml::trusted("<li data-key=later>later</li>"),
},
"Insert",
),
(
Effect::Insert {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)),
position: InsertPosition::First,
html: SafeHtml::trusted("<li data-key=first>first</li>"),
},
"Insert",
),
] {
let fragment = hemx_test::inspect(effect)
.target_html_fragment(TestTarget(ResourceKind::Slot, 2))
.unwrap();
fragment.assert_count("li[data-key]", 1);
assert!(fragment.origin().contains(operation));
}
}
#[test]
fn target_html_errors_explain_missing_non_html_and_ambiguous_effects() {
let missing = hemx_test::inspect(Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9)),
mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>other</p>"),
})
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
.unwrap_err()
.to_string();
assert!(missing.contains("generated target"), "{missing}");
assert!(missing.contains("found none"), "{missing}");
assert!(missing.contains("all effects"), "{missing}");
assert!(missing.contains("other"), "{missing}");
let non_html = hemx_test::inspect(Effect::Focus {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
prevent_scroll: false,
})
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
.unwrap_err()
.to_string();
assert!(non_html.contains("found none"), "{non_html}");
assert!(non_html.contains("Focus"), "{non_html}");
let ambiguous = hemx_test::inspect(vec![
Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>one</p>"),
},
Effect::Insert {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
position: InsertPosition::Last,
html: SafeHtml::trusted("<p>two</p>"),
},
])
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
.unwrap_err()
.to_string();
assert!(ambiguous.contains("found 2"), "{ambiguous}");
assert!(ambiguous.contains("cannot choose"), "{ambiguous}");
assert!(ambiguous.contains("one"), "{ambiguous}");
assert!(ambiguous.contains("two"), "{ambiguous}");
}
#[test]
fn selector_and_assertion_failures_are_actionable() {
let inspected = hemx_test::inspect_html_fragment(
r#"<section><p class="actual">first</p><p class="actual">second</p></section>"#,
);
let invalid = inspected.select("section[").unwrap_err().to_string();
assert!(invalid.contains("invalid CSS selector"), "{invalid}");
assert!(invalid.contains("section["), "{invalid}");
assert!(invalid.contains("HTML fragment"), "{invalid}");
let missing = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|| inspected.assert_exists("article.missing"),
)));
assert!(missing.contains("article.missing"), "{missing}");
assert!(missing.contains("found none"), "{missing}");
assert!(missing.contains("class=\"actual\""), "{missing}");
let duplicate = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|| inspected.assert_text("p.actual", "first"),
)));
assert!(duplicate.contains("exactly one"), "{duplicate}");
assert!(duplicate.contains("found 2"), "{duplicate}");
assert!(duplicate.contains("first"), "{duplicate}");
assert!(duplicate.contains("second"), "{duplicate}");
let wrong_attribute = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|| inspected.assert_attribute("p:first-child", "class", "expected"),
)));
assert!(wrong_attribute.contains("data") || wrong_attribute.contains("class"));
assert!(wrong_attribute.contains("expected"), "{wrong_attribute}");
assert!(wrong_attribute.contains("actual"), "{wrong_attribute}");
}
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
+57 -255
View File
@@ -1,8 +1,18 @@
use hemx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, KeyedSlot, NavigateMode,
Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, HistoryMode,
InsertPosition, KeyedSlot, PatchMode, ResourceId, ResourceKind, ResourceRef, SafeHtml,
ScopeKey, Slot,
};
#[derive(Clone, Copy)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
fn panic_text<T>(result: std::thread::Result<T>) -> String {
let panic = match result {
Ok(_) => panic!("operation must panic"),
@@ -26,24 +36,26 @@ fn inspects_tuple_effects() {
assert!(inspected.has_slot(count));
assert!(inspected.has_atom(user));
assert!(inspected.contains(&Effect::Put {
assert!(inspected.contains(&Effect::Patch {
target: ResourceRef::unscoped(count.id()),
payload: Payload::text(42),
mode: PatchMode::Morph,
html: SafeHtml::trusted("42"),
}));
}
#[test]
fn text_update_condition_is_bound_to_the_expected_target() {
// req: test/018
let expected_target = TestTarget(ResourceKind::Slot, 42);
let inspected = hemx_test::inspect(vec![
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42)),
payload: Payload::Text(String::from("wrong payload")),
mode: PatchMode::Morph,
html: SafeHtml::trusted("wrong payload"),
},
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
payload: Payload::Text(String::from("expected fragment")),
mode: PatchMode::Morph,
html: SafeHtml::trusted("expected fragment"),
},
]);
@@ -68,11 +80,11 @@ fn text_update_condition_is_bound_to_the_expected_target() {
#[test]
fn html_update_assertion_reports_expectation_and_actual_effects() {
// req: test/018
let target = TestTarget(ResourceKind::Slot, 42);
let inspected = hemx_test::inspect(Effect::Put {
let inspected = hemx_test::inspect(Effect::Patch {
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
payload: Payload::Html(String::from("<p>actual</p>")),
mode: PatchMode::Morph,
html: SafeHtml::trusted("<p>actual</p>"),
});
let panic = std::panic::catch_unwind(|| {
@@ -101,65 +113,64 @@ fn finds_keyed_slot_targets() {
#[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 expected_emit = Effect::Dispatch {
event: "saved".into(),
payload: b"card 42 saved".to_vec(),
};
let inspected = hemx_test::inspect(vec![
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(other.__hemx_resource_id()),
payload: Payload::Text("decoy needle".into()),
mode: PatchMode::Morph,
html: SafeHtml::trusted("decoy needle"),
},
Effect::Put {
Effect::Patch {
target: ResourceRef::unscoped(resource),
payload: Payload::Text("expected text".into()),
mode: PatchMode::Morph,
html: SafeHtml::trusted("expected text"),
},
Effect::Put {
Effect::Patch {
target: keyed_ref.clone(),
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()),
mode: PatchMode::Replace,
html: SafeHtml::trusted("<li data-key=\"row-1\">replacement</li>"),
},
Effect::Insert {
target: ResourceRef::unscoped(resource),
key: "row-2".into(),
payload: Payload::Html("<li>inserted</li>".into()),
position: InsertPosition::After(ResourceRef::scoped(
resource,
ScopeKey::KeyValue("row-1".into()),
)),
html: SafeHtml::trusted("<li data-hemx-key=\"row-2\">inserted</li>"),
},
Effect::Prepend {
Effect::Insert {
target: ResourceRef::unscoped(resource),
key: "row-0".into(),
payload: Payload::Html("<li>prepended</li>".into()),
position: InsertPosition::First,
html: SafeHtml::trusted("<li data-hemx-key=\"row-0\">prepended</li>"),
},
Effect::Remove {
target: ResourceRef::unscoped(resource),
key: Some("row-old".into()),
target: ResourceRef::scoped(resource, ScopeKey::KeyValue("row-old".into())),
},
Effect::Focus {
target: ResourceRef::unscoped(form.id()),
prevent_scroll: false,
},
Effect::Navigate {
Effect::Visit {
url: "/cards/42".into(),
mode: NavigateMode::Push,
scroll: hemx_core::ScrollBehavior::Preserve,
title: None,
history: HistoryMode::Push,
},
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_eq!(inspected.op_count(), 9);
assert!(inspected.contains(&expected_emit));
assert!(!inspected.contains(&Effect::Emit {
name: "saved".into(),
payload: "wrong".into(),
assert!(!inspected.contains(&Effect::Dispatch {
event: "saved".into(),
payload: b"wrong".to_vec(),
}));
assert!(inspected.has_resource(resource));
assert!(inspected.has_resource(other.__hemx_resource_id()));
@@ -195,11 +206,11 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
);
assert_eq!(
inspected.target_html_containing(expected, "inserted"),
Some("<li>inserted</li>")
Some("<li data-hemx-key=\"row-2\">inserted</li>")
);
assert_eq!(
inspected.target_html_containing(expected, "prepended"),
Some("<li>prepended</li>")
Some("<li data-hemx-key=\"row-0\">prepended</li>")
);
assert_eq!(inspected.target_html_containing(expected, "missing"), None);
assert!(inspected.emits("saved", "card 42 saved"));
@@ -219,120 +230,26 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
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!(panic_text(std::panic::catch_unwind(|| {
hemx_test::class_selector(invalid)
}))
.contains("class selector part"));
}
for (call, label) in [
(
std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")),
"element selector part",
),
(
std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")),
"class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")),
"parent class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")),
"element selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")),
"class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")),
"parent class selector part",
),
(
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")),
"element selector part",
),
] {
assert!(panic_text(call).contains(label));
}
}
#[test]
fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() {
let batch = EffectBatch {
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
fingerprint: BuildFingerprint(9),
ops: vec![Effect::Emit {
name: "saved".into(),
payload: "ok".into(),
ops: vec![Effect::Dispatch {
event: "saved".into(),
payload: b"ok".to_vec(),
}],
};
assert!(hemx_test::inspect_wire(&batch.to_wire()).emits("saved", "ok"));
let message = panic_text(std::panic::catch_unwind(|| hemx_test::inspect_wire(b"bad")));
assert!(message.contains("invalid hemx effect wire response: Truncated"));
// req: test/001 test req: wire/009 test
}
#[test]
@@ -348,118 +265,3 @@ fn builds_generated_handle_form_bodies() {
);
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
}
#[test]
fn builds_authoring_boundary_selectors() {
assert_eq!(
hemx_test::root_selector("techdemo"),
r#"[data-hemx-root="techdemo"]"#
);
assert_eq!(
hemx_test::island_selector("orbit"),
r#"[data-hemx-island="orbit"]"#
);
assert_eq!(hemx_test::island_attribute_name(), "data-hemx-island");
assert_eq!(hemx_test::island_event_name("orbit"), "hemx:island-orbit");
assert_eq!(
hemx_test::sse_endpoint_marker("/events"),
r#"data-hemx-sse="/events""#
);
assert_eq!(hemx_test::any_root_selector(), "[data-hemx-root]");
assert_eq!(
hemx_test::root_element_selector("main", "docs"),
r#"main[data-hemx-root="docs"]"#
);
assert_eq!(hemx_test::document_body_selector(), "body");
assert_eq!(hemx_test::document_title_selector(), "title");
assert_eq!(
hemx_test::runtime_script_selector(),
r#"script[src^="/hemx."][src$=".js"]"#
);
assert_eq!(
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
r#"[data-sid="42"]"#
);
hemx_test::assert_rendered_target(
TestTarget(ResourceKind::Slot, 42),
"gallery::search_result",
r#"<li data-sid="42">row</li>"#,
);
assert_eq!(
hemx_test::handle_button_selector(hemx_core::Handle::<()>::new(7)),
r#"button[data-hid="7"]"#
);
hemx_test::assert_rendered_handle(
hemx_core::Handle::<()>::new(7),
"gallery::search",
r#"<button data-hid="7">Search</button>"#,
);
assert_eq!(hemx_test::article_selector(), "article");
assert_eq!(hemx_test::strong_text_selector(), "strong");
assert_eq!(hemx_test::small_text_selector(), "small");
assert_eq!(hemx_test::escaped_markup_selector("b"), "b");
assert_eq!(hemx_test::heading_selector("article", 1), "article h1");
assert_eq!(hemx_test::list_item_selector("ul"), "ul li");
assert_eq!(hemx_test::prose_selector("article"), "article p");
assert_eq!(hemx_test::form_selector("header"), "header form");
assert_eq!(
hemx_test::select_options_selector("column"),
r#"select[name="column"] > option"#
);
assert_eq!(hemx_test::class_selector("lane"), ".lane");
assert_eq!(
hemx_test::element_class_selector("span", "presence"),
"span.presence"
);
assert_eq!(
hemx_test::class_child_selector("columns", "section", "column"),
".columns > section.column"
);
assert_eq!(
hemx_test::class_descendant_selector("impact", "i"),
".impact i"
);
assert_eq!(hemx_test::disabled_button_selector(), "button[disabled]");
assert_eq!(
hemx_test::nav_link_selector("/architecture"),
r#"a[href="/architecture"]"#
);
assert_eq!(
hemx_test::page_nav_link_selector("/docs"),
r#"a[href="/docs"][data-hemx-nav]:not([data-hemx-handle])"#
);
assert_eq!(hemx_test::island_snapshot_marker(), "data-island-snapshot=");
assert_eq!(
hemx_test::island_readout_selector(),
"[data-island-readout]"
);
assert_eq!(
hemx_test::scoped_island_readout_selector("#probe-island"),
"#probe-island [data-island-readout]"
);
assert_eq!(
hemx_test::keyed_selector(".work-card", 4),
r#".work-card[data-key="4"]"#
);
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
let probe = hemx_test::island_probe_script(
"probe-\"island",
"orbit\\bridge",
"line1\nline2\rline3\tend",
"detail \"quoted\"",
);
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)]
struct TestTarget(ResourceKind, u32);
impl GeneratedTarget for TestTarget {
fn __hemx_resource_id(self) -> ResourceId {
ResourceId::new(self.0, self.1)
}
}
+228 -79
View File
@@ -1,116 +1,265 @@
use hemx_test::TestProcess;
use hemx_test::{ProcessError, TestProcess};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::process::Command;
use std::time::{Duration, Instant};
#[test]
fn process_harness_reports_early_exit_with_context() {
// req: test/019
fn current_test_command(helper: &str) -> Command {
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
command.arg("--exact").arg(helper).arg("--nocapture");
command
.arg("--exact")
.arg("helper_process_exits_successfully")
.arg("--nocapture");
let error = match TestProcess::start(
command,
"short-lived helper",
"127.0.0.1:9",
Duration::from_secs(2),
) {
Ok(_) => panic!("a process that exits before readiness must fail startup"),
Err(error) => error,
};
let message = error.to_string();
assert!(message.contains("short-lived helper"), "{message}");
assert!(message.contains("127.0.0.1:9"), "{message}");
assert!(message.contains("exited with"), "{message}");
}
#[test]
fn process_harness_waits_for_readiness_and_reaps_on_drop() {
// test req: test/019
fn unused_loopback_addr() -> String {
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = reservation.local_addr().unwrap().to_string();
let address = reservation.local_addr().unwrap().to_string();
drop(reservation);
address
}
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);
fn wait_until_closed(address: &str) {
let deadline = Instant::now() + Duration::from_secs(2);
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline {
while TcpStream::connect(address).is_ok() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
TcpStream::connect(&addr).is_err(),
"drop must reap the helper"
TcpStream::connect(address).is_err(),
"child listener {address} must be closed"
);
}
#[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"));
fn builder_waits_for_delayed_tcp_readiness_and_captures_output() {
let address = unused_loopback_addr();
let process = TestProcess::builder(current_test_command("helper_process_listens"))
.label("delayed TCP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.env("HEMX_TEST_PROCESS_DELAY_MS", "75")
.tcp(&address)
.timeout(Duration::from_secs(2))
.poll_interval(Duration::from_millis(10))
.start()
.expect("readiness must observe the helper listener");
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}");
assert!(process.id().is_some());
assert!(TcpStream::connect(&address).is_ok());
assert!(process.stdout().contains("tcp helper ready"));
drop(process);
wait_until_closed(&address);
}
#[test]
fn helper_process_exits_successfully() {}
fn builder_waits_for_successful_http_readiness() {
let address = unused_loopback_addr();
let process = TestProcess::builder(current_test_command("helper_process_serves_http"))
.label("HTTP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.http(&address, "/health")
.timeout(Duration::from_secs(2))
.poll_interval(Duration::from_millis(10))
.start()
.expect("the second health response is successful");
assert!(process.stdout().contains("http helper ready"));
drop(process);
wait_until_closed(&address);
}
#[test]
fn early_exit_reports_bounded_stdout_and_stderr() {
let error = TestProcess::builder(current_test_command("helper_process_is_noisy"))
.label("noisy helper")
.env("HEMX_TEST_PROCESS_NOISY", "1")
.tcp("127.0.0.1:9")
.output_limit(256)
.timeout(Duration::from_secs(2))
.start()
.unwrap_err();
let message = error.to_string();
assert!(matches!(error, ProcessError::EarlyExit { .. }));
assert!(message.contains("noisy helper"), "{message}");
assert!(message.contains("exited with"), "{message}");
assert!(message.contains("earlier bytes omitted"), "{message}");
assert!(message.contains("stdout marker"), "{message}");
assert!(message.contains("stderr marker"), "{message}");
assert!(
message.len() < 1_500,
"diagnostic was not bounded: {message}"
);
}
#[test]
fn timeout_reports_readiness_attempts_output_and_cleanup() {
let address = unused_loopback_addr();
let error = TestProcess::builder(current_test_command("helper_process_stays_unready"))
.label("unready HTTP helper")
.env("HEMX_TEST_PROCESS_ADDR", &address)
.http(&address, "/health")
.timeout(Duration::from_millis(100))
.poll_interval(Duration::from_millis(10))
.start()
.unwrap_err();
let message = error.to_string();
assert!(matches!(error, ProcessError::TimedOut { .. }));
assert!(message.contains("timed out"), "{message}");
assert!(message.contains("unready HTTP helper"), "{message}");
assert!(message.contains("readiness attempts"), "{message}");
assert!(message.contains("unready helper started"), "{message}");
}
#[test]
fn configuration_spawn_and_occupied_http_fail_honestly() {
let missing_readiness = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("unconfigured helper")
.start()
.unwrap_err();
assert!(matches!(
missing_readiness,
ProcessError::Configuration { .. }
));
let invalid_http = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("invalid HTTP helper")
.http("127.0.0.1:9", "health")
.start()
.unwrap_err();
assert!(invalid_http
.to_string()
.contains("path must start with '/'"));
let spawn = TestProcess::builder(Command::new("/definitely/not/a/hemx/executable"))
.label("missing helper")
.tcp("127.0.0.1:9")
.start()
.unwrap_err();
assert!(matches!(spawn, ProcessError::Spawn { .. }));
assert!(spawn.to_string().contains("failed to spawn missing helper"));
let occupied = TcpListener::bind("127.0.0.1:0").unwrap();
let address = occupied.local_addr().unwrap().to_string();
let occupied_error = TestProcess::builder(current_test_command("helper_process_sleeps"))
.label("HTTP ownership helper")
.env("HEMX_TEST_PROCESS_SLEEP", "1")
.http(&address, "/health")
.timeout(Duration::from_millis(100))
.poll_interval(Duration::from_millis(10))
.start()
.unwrap_err();
assert!(matches!(occupied_error, ProcessError::TimedOut { .. }));
drop(occupied);
}
#[test]
fn explicit_shutdown_and_drop_after_panic_reap_the_child() {
let address = unused_loopback_addr();
let mut process = TestProcess::builder(current_test_command("helper_process_listens"))
.env("HEMX_TEST_PROCESS_ADDR", &address)
.tcp(&address)
.start()
.unwrap();
process.shutdown().unwrap();
process.shutdown().unwrap();
assert!(process.id().is_none());
assert!(process.exit_status().is_some());
wait_until_closed(&address);
let panic_address = unused_loopback_addr();
let result = std::panic::catch_unwind(|| {
let _process = TestProcess::builder(current_test_command("helper_process_listens"))
.env("HEMX_TEST_PROCESS_ADDR", &panic_address)
.tcp(&panic_address)
.start()
.unwrap();
panic!("exercise panic cleanup");
});
assert!(result.is_err());
wait_until_closed(&panic_address);
}
#[test]
fn compatibility_start_still_waits_for_tcp_and_reaps() {
let address = unused_loopback_addr();
let mut command = current_test_command("helper_process_listens");
command.env("HEMX_TEST_PROCESS_ADDR", &address);
let process = TestProcess::start(
command,
"compatibility helper",
&address,
Duration::from_secs(2),
)
.unwrap();
drop(process);
wait_until_closed(&address);
}
#[test]
fn helper_process_listens() {
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let _listener = TcpListener::bind(addr).expect("bind helper listener");
if let Ok(delay) = std::env::var("HEMX_TEST_PROCESS_DELAY_MS") {
std::thread::sleep(Duration::from_millis(delay.parse().unwrap()));
}
let _listener = TcpListener::bind(address).expect("bind helper listener");
println!("tcp helper ready");
std::thread::sleep(Duration::from_secs(10));
}
#[test]
fn helper_process_serves_http() {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let listener = TcpListener::bind(address).expect("bind HTTP helper listener");
println!("http helper ready");
for status in ["503 Service Unavailable", "204 No Content"] {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 512];
let read = stream.read(&mut request).unwrap();
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /health HTTP/1.1"));
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
}
std::thread::sleep(Duration::from_secs(10));
}
#[test]
fn helper_process_stays_unready() {
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
return;
};
let listener = TcpListener::bind(address).expect("bind unready helper listener");
println!("unready helper started");
loop {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 512];
let _ = stream.read(&mut request);
write!(
stream,
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.unwrap();
}
}
#[test]
fn helper_process_is_noisy() {
if std::env::var_os("HEMX_TEST_PROCESS_NOISY").is_none() {
return;
}
println!("{}\nstdout marker", "o".repeat(4_096));
eprintln!("{}\nstderr marker", "e".repeat(4_096));
}
#[test]
fn helper_process_sleeps() {
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
println!("sleeping helper started");
std::thread::sleep(Duration::from_secs(10));
}
}