Implement closed typed effect path
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 & notes</title></head>
|
||||
<body><main><h1> Todos today </h1><p data-state="ready">two < 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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user