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
+19 -3
View File
@@ -1,10 +1,26 @@
[package]
name = "hemx-test"
version.workspace = true
version = "0.4.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Test support for Hemx applications"
keywords = ["hypermedia", "testing", "web"]
categories = ["development-tools::testing", "web-programming"]
[features]
default = []
axum = ["dep:axum", "dep:tower"]
[dependencies]
hemx-core = { path = "../hemx-core" }
axum = { version = "0.8.8", optional = true, default-features = false }
hemx-core = { version = "0.3.0", path = "../hemx-core" }
scraper = "0.25.0"
tower = { version = "0.5.2", optional = true, features = ["util"] }
[dev-dependencies]
hemx-build = { path = "../hemx-build" }
hemx-axum = { version = "0.3.0", path = "../hemx-axum" }
hemx-build = { version = "0.3.0", path = "../hemx-build" }
tokio = { version = "1.48.0", features = ["macros", "rt"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+214
View File
@@ -0,0 +1,214 @@
# hemx-test
Test support for Hemx applications and generated resources.
`hemx-test` keeps assertions at the same typed boundary as application code. Use
an `EffectInspector` with generated targets instead of copying resource IDs or
matching raw wire operations.
## Choose the owning boundary
| Behavior under test | Smallest authoritative proof |
| --- | --- |
| Domain invariants and state transitions | Ordinary Rust unit tests; no Hemx harness |
| Handler output and generated-target effects | `run`, `run_async`, `run_result`, or `run_async_result` plus `EffectInspector` |
| Static document or effect-fragment structure | `HtmlInspector`, reached directly or through `target_html_document` / `target_html_fragment` |
| Axum extraction, middleware, status, headers, and response body | The opt-in `hemx_test::axum` router harness |
| Real process startup, readiness, sockets, logs, and cleanup | `TestProcess::builder` |
| JavaScript, focus, history, layout, or runtime reconciliation | A focused real-browser test outside `hemx-test` |
## Effects and structural HTML
```rust
use hemx_core::{GeneratedTarget, SafeHtml, Slot};
# use hemx_core::{Effect, ResourceId};
# #[derive(Clone, Copy)]
# struct GeneratedTodos(Slot<()>);
# impl GeneratedTarget for GeneratedTodos {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
# impl GeneratedTodos {
# fn html(self, value: SafeHtml) -> Effect { self.0.html(value) }
# }
// Application build output supplies generated target types like this one.
let todos = GeneratedTodos(Slot::new(7));
let effect = todos.html(SafeHtml::trusted(
r#"<ul><li class="todo" data-state="open">Write tests</li></ul>"#,
));
let inspected = hemx_test::inspect(effect);
assert!(inspected.updates_html(todos));
let html = inspected.target_html_fragment(todos)?;
html.assert_count("li.todo", 1);
html.assert_text("li.todo", "Write tests");
html.assert_attribute("li.todo", "data-state", "open");
# Ok::<(), hemx_test::HtmlInspectionError>(())
```
Complete server-rendered pages can be inspected directly:
```rust
let page = hemx_test::inspect_html_document(
"<!doctype html><html><body><main id=app data-sid=7>Ready</main></body></html>",
);
page.assert_text("main#app", "Ready");
# use hemx_core::{GeneratedTarget, ResourceId, Slot};
# #[derive(Clone, Copy)]
# struct GeneratedApp(Slot<()>);
# impl GeneratedTarget for GeneratedApp {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
page.assert_target(GeneratedApp(Slot::new(7)));
```
Structural inspection is backed internally by an HTML parser, but its types are
not part of the public API. `HtmlInspector`, `HtmlSelection`, and `HtmlElement`
own the observable source and selected data. Structural inspection does not run
JavaScript or prove browser-owned behavior such as focus, history, layout, or
runtime reconciliation.
A target inspector requires exactly one `Put`, `Insert`, or `Prepend` HTML
payload for that generated target. Missing, non-HTML, and ambiguous target
effects return `HtmlInspectionError` with the relevant effects in the
diagnostic.
## Handlers
`run` and `run_async` invoke typed synchronous and asynchronous handlers and
return the same `EffectInspector`:
```rust
use hemx_core::{Effect, GeneratedTarget, ResourceId, Slot};
# #[derive(Clone, Copy)]
# struct GeneratedCount(Slot<u32>);
# impl GeneratedTarget for GeneratedCount {
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
# }
async fn load_count(value: u32) -> Effect {
Slot::<u32>::new(1).text(value)
}
# async fn example() {
let count = GeneratedCount(Slot::new(1));
let inspected = hemx_test::run_async(load_count, 42).await;
assert!(inspected.updates_text_containing(count, "42"));
# }
```
Fallible handlers use `run_result` or `run_async_result`. Their concrete error
is returned unchanged and is never converted into an empty or success-looking
effect batch:
```rust
use hemx_core::{Effect, Slot};
#[derive(Debug, Eq, PartialEq)]
struct Rejected;
async fn save(accepted: bool) -> Result<Effect, Rejected> {
accepted
.then(|| Slot::<()>::new(1).text("saved"))
.ok_or(Rejected)
}
# async fn example() {
let error = hemx_test::run_async_result(save, false)
.await
.unwrap_err();
assert_eq!(error, Rejected);
# }
```
`IntoEffect` conversion itself is infallible in the current public contract, so
handler errors and inspected success effects remain distinct.
## Axum routers
Enable the `axum` feature to send owned requests through a real `axum::Router`.
The response always preserves status, headers, and raw body bytes; parse it as a
Hemx effect batch or structural HTML only when that is the response contract.
```rust
# #[cfg(feature = "axum")]
# async fn example() -> Result<(), hemx_test::axum::RouterTestError> {
use axum::{response::Html, routing::get, Router};
use axum::http::StatusCode;
let app = Router::new().route(
"/",
get(|| async { Html("<main id=app>Ready</main>") }),
);
let response = hemx_test::axum::get("/").send(app).await?;
assert_eq!(response.status(), StatusCode::OK);
response.html_fragment()?.assert_text("main#app", "Ready");
# Ok(())
# }
```
`post(...).form(handle, fields)` builds the URL-encoded interaction body from a
typed Hemx handle. Authentication, CSRF, sessions, persistence, middleware, and
test providers remain application-owned. This in-process harness does not prove
real sockets, process startup, or browser behavior.
## Processes
Use `TestProcess` only when the process and socket lifecycle are part of the
behavior under test. Readiness is explicit and bounded:
```rust,no_run
use hemx_test::TestProcess;
use std::process::Command;
use std::time::Duration;
let process = TestProcess::builder(Command::new("target/debug/my-app"))
.label("application server")
.arg("serve")
.env("APP_ADDR", "127.0.0.1:4100")
.http("127.0.0.1:4100", "/health")
.timeout(Duration::from_secs(5))
.start()?;
assert!(process.id().is_some());
# Ok::<(), hemx_test::ProcessError>(())
```
TCP readiness proves only that something accepts the address; HTTP readiness
requires a 2xx or 3xx response from the selected path. Startup errors include
the process label, readiness attempts, exit status when available, and bounded
stdout/stderr. Readers keep draining after the capture limit so noisy children
do not deadlock. Explicit `shutdown` and `Drop` both kill, wait for, and reap a
running child and are safe to call more than once.
`TestProcess::start` remains available as the compact compatibility entry point
for TCP readiness. The harness intentionally does not reserve ports or claim
that a reserve-then-bind handoff is atomic.
## Migrating selector helpers
The original `0.1.0` crate exposed app-specific CSS builders and island/browser
probe helpers. They duplicated CSS syntax, made application structure look like
a framework contract, and could not prove browser behavior. They are removed
from the next release rather than preserved as a second testing vocabulary.
- Replace semantic selector builders such as `article_selector`,
`class_selector`, and `nav_link_selector` with the CSS selector that expresses
the application's own HTML contract in `HtmlInspector`.
- Replace `assert_rendered_target` and `assert_rendered_handle` with
`HtmlInspector::assert_target` and `HtmlInspector::assert_handle`; generated
resources remain the assertion vocabulary and raw runtime IDs stay private.
- Replace `target_selector`, `handle_selector`, and keyed selector builders with
structural HTML assertions. Browser tests that genuinely need a selector
should keep that selector in their browser-test adapter.
- Replace island probe scripts, readout selectors, event-name helpers, and SSE
marker strings with a focused real-browser journey; `hemx-test` does not
emulate JavaScript or runtime behavior.
The generated-resource helpers for effect inspection and typed form bodies
remain supported.
## License
MIT
+212
View File
@@ -0,0 +1,212 @@
use crate::{handle_form_body, inspect_html_document, inspect_html_fragment, try_inspect_wire};
use axum::body::{to_bytes, Body};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode};
use axum::Router;
use hemx_core::{Handle, WireError};
use std::error::Error;
use std::fmt;
use tower::ServiceExt;
// Keep this private until the hemx-core release carrying its public constant is
// the minimum supported dependency of hemx-test.
const HEMX_CONTENT_TYPE: &str = "application/hemx";
const HTML_CONTENT_TYPE: &str = "text/html";
const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024;
/// Build a request against a real Axum [`Router`].
pub fn request(method: Method, uri: impl Into<String>) -> RouterRequest {
RouterRequest {
method,
uri: uri.into(),
headers: HeaderMap::new(),
body: Vec::new(),
body_limit: DEFAULT_BODY_LIMIT,
}
}
/// Build a GET request against a real Axum [`Router`].
pub fn get(uri: impl Into<String>) -> RouterRequest {
request(Method::GET, uri)
}
/// Build a POST request against a real Axum [`Router`].
pub fn post(uri: impl Into<String>) -> RouterRequest {
request(Method::POST, uri)
}
/// An owned request builder for exercising a real Axum router in process.
#[derive(Clone, Debug)]
pub struct RouterRequest {
method: Method,
uri: String,
headers: HeaderMap,
body: Vec<u8>,
body_limit: usize,
}
impl RouterRequest {
pub fn header(mut self, name: HeaderName, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
}
pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
self.body = body.into();
self
}
/// Set a URL-encoded Hemx interaction form body from a typed handle.
pub fn form<I>(mut self, handle: Handle<I>, fields: &[(&str, &str)]) -> Self {
self.headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(FORM_CONTENT_TYPE),
);
self.body = handle_form_body(handle, fields).into_bytes();
self
}
/// Bound the buffered response body. The default is 2 MiB.
pub fn body_limit(mut self, bytes: usize) -> Self {
self.body_limit = bytes;
self
}
/// Send this request through a real Axum router and buffer its response.
pub async fn send(self, router: Router) -> Result<RouterResponse, RouterTestError> {
let mut request = Request::builder()
.method(self.method)
.uri(&self.uri)
.body(Body::from(self.body))
.map_err(|error| RouterTestError::Request(error.to_string()))?;
*request.headers_mut() = self.headers;
let response = router
.oneshot(request)
.await
.map_err(|error| RouterTestError::Router(error.to_string()))?;
let (parts, body) = response.into_parts();
let bytes =
to_bytes(body, self.body_limit)
.await
.map_err(|error| RouterTestError::Body {
limit: self.body_limit,
message: error.to_string(),
})?;
Ok(RouterResponse {
status: parts.status,
headers: parts.headers,
body: bytes.to_vec(),
})
}
}
/// An owned Axum response preserving status, headers, and raw body bytes.
#[derive(Clone, Debug)]
pub struct RouterResponse {
status: StatusCode,
headers: HeaderMap,
body: Vec<u8>,
}
impl RouterResponse {
pub fn status(&self) -> StatusCode {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn body(&self) -> &[u8] {
&self.body
}
pub fn content_type(&self) -> Option<&str> {
self.headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
}
pub fn text(&self) -> Result<&str, RouterTestError> {
std::str::from_utf8(&self.body).map_err(|error| RouterTestError::Utf8(error.to_string()))
}
/// Decode an `application/hemx` response into an effect inspector.
pub fn effects(&self) -> Result<crate::EffectInspector, RouterTestError> {
self.expect_content_type(HEMX_CONTENT_TYPE)?;
try_inspect_wire(&self.body).map_err(RouterTestError::Wire)
}
/// Parse a `text/html` response as a complete document.
pub fn html_document(&self) -> Result<crate::HtmlInspector, RouterTestError> {
self.expect_content_type(HTML_CONTENT_TYPE)?;
let source = self.text()?.to_owned();
Ok(inspect_html_document(source))
}
/// Parse a `text/html` response as a fragment.
pub fn html_fragment(&self) -> Result<crate::HtmlInspector, RouterTestError> {
self.expect_content_type(HTML_CONTENT_TYPE)?;
let source = self.text()?.to_owned();
Ok(inspect_html_fragment(source))
}
fn expect_content_type(&self, expected: &'static str) -> Result<(), RouterTestError> {
let actual = self.content_type().map(str::to_owned);
let media_type = actual
.as_deref()
.and_then(|value| value.split(';').next())
.map(str::trim);
if media_type == Some(expected) {
Ok(())
} else {
Err(RouterTestError::ContentType { expected, actual })
}
}
}
/// A request, router, body, content-type, UTF-8, or Hemx wire failure.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RouterTestError {
Request(String),
Router(String),
Body {
limit: usize,
message: String,
},
ContentType {
expected: &'static str,
actual: Option<String>,
},
Utf8(String),
Wire(WireError),
}
impl fmt::Display for RouterTestError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Request(message) => {
write!(formatter, "could not build Axum test request: {message}")
}
Self::Router(message) => write!(
formatter,
"Axum router failed to serve test request: {message}"
),
Self::Body { limit, message } => write!(
formatter,
"could not buffer Axum response body within {limit} bytes: {message}"
),
Self::ContentType { expected, actual } => write!(
formatter,
"expected response content type {expected:?}, found {}",
actual.as_deref().unwrap_or("no content type")
),
Self::Utf8(message) => write!(formatter, "response body is not valid UTF-8: {message}"),
Self::Wire(error) => write!(formatter, "invalid Hemx effect response: {error:?}"),
}
}
}
impl Error for RouterTestError {}
+340
View File
@@ -0,0 +1,340 @@
use hemx_core::{GeneratedTarget, Handle, ResourceKind};
use scraper::{Html, Selector};
use std::collections::BTreeMap;
use std::error::Error;
use std::fmt;
use std::sync::Arc;
/// An owned, parsed HTML document or fragment.
///
/// The underlying parser is intentionally private so application tests do not
/// become coupled to `scraper`'s public types.
pub struct HtmlInspector {
source: Arc<str>,
origin: Arc<str>,
parsed: Html,
}
impl fmt::Debug for HtmlInspector {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HtmlInspector")
.field("origin", &self.origin)
.field("source", &self.source)
.finish_non_exhaustive()
}
}
impl HtmlInspector {
pub(crate) fn document(source: String, origin: String) -> Self {
Self {
parsed: Html::parse_document(&source),
source: source.into(),
origin: origin.into(),
}
}
pub(crate) fn fragment(source: String, origin: String) -> Self {
Self {
parsed: Html::parse_fragment(&source),
source: source.into(),
origin: origin.into(),
}
}
/// Return the original HTML supplied to the inspector.
pub fn source(&self) -> &str {
&self.source
}
/// Describe where the inspected HTML came from.
pub fn origin(&self) -> &str {
&self.origin
}
/// Select elements with a CSS selector and copy their observable structure
/// into an owned result.
pub fn select(&self, selector: &str) -> Result<HtmlSelection, HtmlInspectionError> {
let parsed_selector = Selector::parse(selector).map_err(|error| {
HtmlInspectionError::new(format!(
"invalid CSS selector {selector:?} while inspecting {}: {error:?}",
self.origin
))
})?;
let elements = self
.parsed
.select(&parsed_selector)
.map(|element| HtmlElement {
name: element.value().name().to_owned(),
text: normalize_text(element.text()),
attributes: element
.value()
.attrs()
.map(|(name, value)| (name.to_owned(), value.to_owned()))
.collect(),
html: element.html(),
})
.collect();
Ok(HtmlSelection {
selector: selector.to_owned(),
origin: Arc::clone(&self.origin),
source: Arc::clone(&self.source),
elements,
})
}
/// Select elements carrying the runtime marker for a generated target.
pub fn select_target(&self, target: impl GeneratedTarget) -> HtmlSelection {
let resource = target.__hemx_resource_id();
self.selection_or_panic(&attribute_selector(
resource_attribute(resource.kind),
&resource.id.to_string(),
))
}
/// Assert that rendered HTML contains a generated target marker.
#[track_caller]
pub fn assert_target(&self, target: impl GeneratedTarget) {
self.select_target(target).assert_exists();
}
/// Select elements carrying the runtime marker for a typed handle.
pub fn select_handle<I>(&self, handle: Handle<I>) -> HtmlSelection {
self.selection_or_panic(&attribute_selector("data-hid", &handle.to_string()))
}
/// Assert that rendered HTML contains a typed handle marker.
#[track_caller]
pub fn assert_handle<I>(&self, handle: Handle<I>) {
self.select_handle(handle).assert_exists();
}
/// Assert that at least one element matches a CSS selector.
#[track_caller]
pub fn assert_exists(&self, selector: &str) {
self.selection_or_panic(selector).assert_exists();
}
/// Assert that exactly `expected` elements match a CSS selector.
#[track_caller]
pub fn assert_count(&self, selector: &str, expected: usize) {
self.selection_or_panic(selector).assert_count(expected);
}
/// Assert that one element matches a selector and has the expected
/// whitespace-normalized text.
#[track_caller]
pub fn assert_text(&self, selector: &str, expected: &str) {
self.selection_or_panic(selector).assert_text(expected);
}
/// Assert that one element matches a selector and has an exact attribute
/// value.
#[track_caller]
pub fn assert_attribute(&self, selector: &str, name: &str, expected: &str) {
self.selection_or_panic(selector)
.assert_attribute(name, expected);
}
#[track_caller]
fn selection_or_panic(&self, selector: &str) -> HtmlSelection {
self.select(selector)
.unwrap_or_else(|error| panic!("{error}"))
}
}
/// An owned set of elements selected from an [`HtmlInspector`].
#[derive(Clone, Debug)]
pub struct HtmlSelection {
selector: String,
origin: Arc<str>,
source: Arc<str>,
elements: Vec<HtmlElement>,
}
impl HtmlSelection {
pub fn selector(&self) -> &str {
&self.selector
}
pub fn len(&self) -> usize {
self.elements.len()
}
pub fn is_empty(&self) -> bool {
self.elements.is_empty()
}
pub fn elements(&self) -> &[HtmlElement] {
&self.elements
}
/// Assert that this selection contains at least one element.
#[track_caller]
pub fn assert_exists(&self) {
assert!(
!self.is_empty(),
"expected at least one element matching {:?} in {}; found none. source: {}",
self.selector,
self.origin,
excerpt(&self.source)
);
}
/// Assert that this selection contains exactly `expected` elements.
#[track_caller]
pub fn assert_count(&self, expected: usize) {
assert_eq!(
self.len(),
expected,
"unexpected match count for selector {:?} in {}. matches: {}. source: {}",
self.selector,
self.origin,
matching_markup(&self.elements),
excerpt(&self.source)
);
}
/// Assert that this selection has one element with exact
/// whitespace-normalized text.
#[track_caller]
pub fn assert_text(&self, expected: &str) {
let element = self.only_element("text");
assert_eq!(
element.text,
expected,
"unexpected text for selector {:?} in {}. element: {}",
self.selector,
self.origin,
excerpt(&element.html)
);
}
/// Assert that this selection has one element with an exact attribute
/// value.
#[track_caller]
pub fn assert_attribute(&self, name: &str, expected: &str) {
let element = self.only_element("an attribute");
let actual = element.attribute(name);
assert_eq!(
actual,
Some(expected),
"unexpected attribute {name:?} for selector {:?} in {}. element: {}",
self.selector,
self.origin,
excerpt(&element.html)
);
}
#[track_caller]
fn only_element(&self, assertion: &str) -> &HtmlElement {
assert_eq!(
self.elements.len(),
1,
"expected exactly one element matching {:?} in {} before asserting {assertion}; found {}. matches: {}. source: {}",
self.selector,
self.origin,
self.elements.len(),
matching_markup(&self.elements),
excerpt(&self.source)
);
&self.elements[0]
}
}
/// Owned observable structure for one selected HTML element.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HtmlElement {
name: String,
text: String,
attributes: BTreeMap<String, String>,
html: String,
}
impl HtmlElement {
pub fn name(&self) -> &str {
&self.name
}
/// Return whitespace-normalized descendant text.
pub fn text(&self) -> &str {
&self.text
}
pub fn attribute(&self, name: &str) -> Option<&str> {
self.attributes.get(name).map(String::as_str)
}
pub fn attributes(&self) -> impl Iterator<Item = (&str, &str)> {
self.attributes
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
}
pub fn html(&self) -> &str {
&self.html
}
}
/// A structural HTML inspection error with an owned diagnostic.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct HtmlInspectionError {
message: String,
}
impl HtmlInspectionError {
pub(crate) fn new(message: String) -> Self {
Self { message }
}
}
impl fmt::Display for HtmlInspectionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl Error for HtmlInspectionError {}
fn resource_attribute(kind: ResourceKind) -> &'static str {
match kind {
ResourceKind::Slot => "data-sid",
ResourceKind::Atom => "data-aid",
ResourceKind::Handle => "data-hid",
ResourceKind::Form => "data-fid",
}
}
fn attribute_selector(name: &str, value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"[{name}="{escaped}"]"#)
}
fn normalize_text<'a>(text: impl Iterator<Item = &'a str>) -> String {
text.flat_map(str::split_whitespace)
.collect::<Vec<_>>()
.join(" ")
}
fn matching_markup(elements: &[HtmlElement]) -> String {
if elements.is_empty() {
return String::from("none");
}
elements
.iter()
.take(4)
.map(|element| excerpt(&element.html))
.collect::<Vec<_>>()
.join(" | ")
}
fn excerpt(value: &str) -> String {
const LIMIT: usize = 500;
if value.chars().count() <= LIMIT {
return value.to_owned();
}
let mut excerpt = value.chars().take(LIMIT).collect::<String>();
excerpt.push('…');
excerpt
}
+188 -530
View File
@@ -1,73 +1,20 @@
#![doc = include_str!("../README.md")]
#[cfg(feature = "axum")]
pub mod axum;
mod html;
mod process;
pub use html::{HtmlElement, HtmlInspectionError, HtmlInspector, HtmlSelection};
pub use process::{ProcessError, ProcessFailure, TestProcess, TestProcessBuilder};
use hemx_core::{
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, IntoEffect, KeyedSlot,
NavigateMode, Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
ResourceId, ResourceRef, ScopeKey, Slot,
};
use std::io;
use std::net::TcpStream;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
/// A child process owned by an integration test and proven ready over TCP.
///
/// The process is killed and reaped on every return path, including panics. Startup failures name
/// the process and address and distinguish early exit from a readiness timeout.
/// req: test/019
pub struct TestProcess {
child: Child,
}
fn test_process_try_wait(child: &mut Child) -> io::Result<Option<ExitStatus>> {
child.try_wait()
}
fn test_process_poll_delay() {
std::thread::sleep(Duration::from_millis(25));
}
impl TestProcess {
pub fn start(
mut command: Command,
label: impl Into<String>,
addr: &str,
timeout: Duration,
) -> io::Result<Self> {
let label = label.into();
let child = command
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|error| {
io::Error::new(error.kind(), format!("failed to spawn {label}: {error}"))
})?;
let mut process = Self { child };
let deadline = Instant::now() + timeout;
loop {
if TcpStream::connect(addr).is_ok() {
return Ok(process);
}
if let Some(status) = test_process_try_wait(&mut process.child)? {
return Err(io::Error::other(format!(
"{label} exited with {status} before listening on {addr}"
)));
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
format!("timed out after {timeout:?} waiting for {label} to listen on {addr}"),
));
}
test_process_poll_delay();
}
}
}
impl Drop for TestProcess {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
use std::future::Future;
/// Run a synchronous handler with typed input and inspect its effects.
pub fn run<I, F, R>(handler: F, input: I) -> EffectInspector
where
F: FnOnce(I) -> R,
@@ -76,6 +23,46 @@ where
inspect(handler(input))
}
/// Run an asynchronous handler with typed input and inspect its effects.
pub async fn run_async<I, F, HandlerFuture, R>(handler: F, input: I) -> EffectInspector
where
F: FnOnce(I) -> HandlerFuture,
HandlerFuture: Future<Output = R>,
R: IntoEffect,
{
inspect(handler(input).await)
}
/// Run a fallible synchronous handler without hiding its concrete error.
///
/// Only the successful value is converted into effects. `IntoEffect`
/// conversion is infallible in the current public contract, so an error from
/// the handler is returned unchanged rather than becoming an empty or
/// success-looking batch.
pub fn run_result<I, F, R, E>(handler: F, input: I) -> Result<EffectInspector, E>
where
F: FnOnce(I) -> Result<R, E>,
R: IntoEffect,
{
handler(input).map(inspect)
}
/// Run a fallible asynchronous handler without hiding its concrete error.
///
/// Only the successful value is converted into effects; the handler's original
/// error type and value are preserved.
pub async fn run_async_result<I, F, HandlerFuture, R, E>(
handler: F,
input: I,
) -> Result<EffectInspector, E>
where
F: FnOnce(I) -> HandlerFuture,
HandlerFuture: Future<Output = Result<R, E>>,
R: IntoEffect,
{
handler(input).await.map(inspect)
}
fn inspection_fingerprint() -> BuildFingerprint {
BuildFingerprint(0)
}
@@ -84,35 +71,43 @@ pub fn inspect(effect: impl IntoEffect) -> EffectInspector {
inspect_batch(effect.into_batch(inspection_fingerprint()))
}
/// Parse and structurally inspect a complete server-rendered HTML document.
pub fn inspect_html_document(html: impl Into<String>) -> HtmlInspector {
HtmlInspector::document(html.into(), String::from("HTML document"))
}
/// Parse and structurally inspect a server-rendered HTML fragment.
pub fn inspect_html_fragment(html: impl Into<String>) -> HtmlInspector {
HtmlInspector::fragment(html.into(), String::from("HTML fragment"))
}
/// Inspect an already-dispatched batch without matching raw effect variants in tests.
/// req: test/001 req: dx/006
pub fn inspect_batch(batch: EffectBatch) -> EffectInspector {
EffectInspector { batch }
}
/// Decode and inspect an effect wire response without exposing `EffectBatch` in tests.
/// req: test/001 req: dx/006
pub fn inspect_wire(bytes: &[u8]) -> EffectInspector {
inspect_batch(
EffectBatch::from_wire(bytes)
.unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}")),
)
try_inspect_wire(bytes)
.unwrap_or_else(|error| panic!("invalid hemx effect wire response: {error:?}"))
}
/// Try to decode and inspect an effect wire response.
pub fn try_inspect_wire(bytes: &[u8]) -> Result<EffectInspector, hemx_core::WireError> {
EffectBatch::from_wire(bytes).map(inspect_batch)
}
/// Return the resource id behind a generated target for low-level test assertions.
/// req: test/001 req: dx/006
pub fn target_resource(target: impl GeneratedTarget) -> ResourceId {
target.__hemx_resource_id()
}
/// Return the unscoped resource reference behind a generated target for low-level test assertions.
/// req: test/001 req: dx/006
pub fn target_ref(target: impl GeneratedTarget) -> ResourceRef {
ResourceRef::unscoped(target_resource(target))
}
/// Build an interaction request body from a generated handle and form fields.
/// req: test/001 req: dx/006
pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]) -> String {
let mut body = form_pair("__h", &handle.to_string());
for (name, value) in fields {
@@ -123,360 +118,10 @@ pub fn handle_form_body<I>(handle: hemx_core::Handle<I>, fields: &[(&str, &str)]
}
/// Build a request body for invalid-handle tests without exposing the wire field name.
/// req: test/001 req: dx/006
pub fn unknown_handle_form_body(id: u32) -> String {
form_pair("__h", &id.to_string())
}
/// Build a browser-driver selector from a generated handle without exposing runtime ids in tests.
/// req: test/001 req: dx/006
pub fn handle_selector<I>(handle: hemx_core::Handle<I>) -> String {
attr_selector("data-hid", &handle.to_string())
}
/// Build a selector for a clickable button with a generated handle.
/// req: test/001 req: dx/006
pub fn handle_button_selector<I>(handle: hemx_core::Handle<I>) -> String {
format!("button{}", handle_selector(handle))
}
/// Build a selector for a heading in a semantic container without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn heading_selector(scope_selector: &str, level: u8) -> String {
assert!((1..=6).contains(&level), "heading level must be 1..=6");
if scope_selector.is_empty() {
format!("h{level}")
} else {
format!("{scope_selector} h{level}")
}
}
/// Build a selector for article content without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn article_selector() -> &'static str {
"article"
}
/// Build a selector for emphasized/card-title text without spelling document structure.
/// req: test/001 req: dx/006
pub fn strong_text_selector() -> &'static str {
"strong"
}
/// Build a selector for secondary/help text without spelling document structure.
/// req: test/001 req: dx/006
pub fn small_text_selector() -> &'static str {
"small"
}
/// Build a selector for an HTML tag that must be absent when user text is escaped.
/// req: test/001 req: dx/006
pub fn escaped_markup_selector(tag: &str) -> String {
assert!(
tag.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"tag selector must be a simple tag name"
);
tag.to_owned()
}
/// Build a selector for list items without spelling document structure in examples.
/// req: test/001 req: dx/006
pub fn list_item_selector(scope_selector: &str) -> String {
if scope_selector.is_empty() {
"li".to_owned()
} else {
format!("{scope_selector} li")
}
}
/// Build a selector for prose text in a semantic container without spelling document structure.
/// req: test/001 req: dx/006
pub fn prose_selector(scope_selector: &str) -> String {
if scope_selector.is_empty() {
"p".to_owned()
} else {
format!("{scope_selector} p")
}
}
/// Build a selector for a form in a semantic container without spelling form structure in examples.
/// req: test/001 req: dx/006
pub fn form_selector(scope_selector: &str) -> String {
format!("{scope_selector} form")
}
/// Build a selector for a form select's options from the authoring field name.
/// req: test/001 req: dx/006
pub fn select_options_selector(field: &str) -> String {
format!("select{} > option", attr_selector("name", field))
}
/// Build a selector for an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn class_selector(class: &str) -> String {
assert_simple_selector_part(class, "class");
format!(".{class}")
}
/// Build a selector for an element carrying an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn element_class_selector(element: &str, class: &str) -> String {
assert_simple_selector_part(element, "element");
assert_simple_selector_part(class, "class");
format!("{element}.{class}")
}
/// Build a selector for classed children inside an app-owned semantic container.
/// req: test/001 req: dx/006
pub fn class_child_selector(parent_class: &str, element: &str, class: &str) -> String {
assert_simple_selector_part(parent_class, "parent class");
assert_simple_selector_part(element, "element");
assert_simple_selector_part(class, "class");
format!(".{parent_class} > {element}.{class}")
}
/// Build a selector for an element inside an app-owned semantic class.
/// req: test/001 req: dx/006
pub fn class_descendant_selector(parent_class: &str, element: &str) -> String {
assert_simple_selector_part(parent_class, "parent class");
assert_simple_selector_part(element, "element");
format!(".{parent_class} {element}")
}
/// Build a selector for disabled action buttons without spelling CSS selector state in examples.
/// req: test/001 req: dx/006
pub fn disabled_button_selector() -> &'static str {
"button[disabled]"
}
/// Build a selector for progressive-enhancement navigation links.
/// req: test/001 req: dx/006
pub fn nav_link_selector(href: &str) -> String {
format!("a{}", attr_selector("href", href))
}
/// Build a selector for page-enhanced navigation links that do not use handler dispatch.
/// req: test/001 req: dx/006
pub fn page_nav_link_selector(href: &str) -> String {
format!(
"{}[data-hemx-nav]:not([data-hemx-handle])",
nav_link_selector(href)
)
}
/// Build a browser-driver selector from a generated target without exposing runtime ids in tests.
/// req: test/001 req: dx/006
pub fn target_selector(target: impl GeneratedTarget) -> String {
let resource = target.__hemx_resource_id();
let attr = runtime_resource_attr(resource.kind);
attr_selector(attr, &resource.id.to_string())
}
/// Assert rendered HTML contains a generated target and report the generated name on failure.
/// req: test/017 req: dx/006
pub fn assert_rendered_target(target: impl GeneratedTarget, generated_name: &str, html: &str) {
let resource = target.__hemx_resource_id();
let marker = runtime_attr_marker(
runtime_resource_attr(resource.kind),
&resource.id.to_string(),
);
assert!(
html.contains(&marker),
"rendered hemx target `{generated_name}` missing runtime marker {marker}"
);
}
/// Assert rendered HTML contains a generated handle and report the generated name on failure.
/// req: test/017 req: dx/006
pub fn assert_rendered_handle<I>(handle: hemx_core::Handle<I>, generated_name: &str, html: &str) {
let marker = runtime_attr_marker("data-hid", &handle.to_string());
assert!(
html.contains(&marker),
"rendered hemx handle `{generated_name}` missing runtime marker {marker}"
);
}
/// Build a selector for an hemx root from its authoring name.
/// req: test/001 req: dx/006
pub fn root_selector(name: &str) -> String {
attr_selector("data-hemx-root", name)
}
/// Build a selector for a specific root element from its authoring name.
/// req: test/001 req: dx/006
pub fn root_element_selector(element: &str, name: &str) -> String {
format!("{}{}", element, root_selector(name))
}
/// Build a selector for the document body without spelling raw document structure in examples.
/// req: test/001 req: dx/006
pub fn document_body_selector() -> &'static str {
"body"
}
/// Build a selector for the document title without spelling raw document structure in examples.
/// req: test/001 req: dx/006
pub fn document_title_selector() -> &'static str {
"title"
}
/// Build a selector for the hemx runtime script without exposing its asset path in tests.
/// req: test/001 req: dx/006
pub fn runtime_script_selector() -> &'static str {
"script[src^=\"/hemx.\"][src$=\".js\"]"
}
/// Build a selector for any hemx root without spelling the attribute in tests.
/// req: test/001 req: dx/006
pub fn any_root_selector() -> &'static str {
"[data-hemx-root]"
}
/// Build a selector for a keyed generated row without spelling runtime key metadata.
/// req: test/001 req: dx/006
pub fn keyed_selector(base_selector: &str, key: impl ToString) -> String {
format!(
"{}{}",
base_selector,
attr_selector("data-key", &key.to_string())
)
}
/// Build a selector for all generated keyed rows under a semantic base selector.
/// req: test/001 req: dx/006
pub fn keyed_items_selector(base_selector: &str) -> String {
format!("{base_selector}[data-key]")
}
/// Build a selector for an island from its authoring name.
/// req: test/001 req: dx/006
pub fn island_selector(name: &str) -> String {
attr_selector("data-hemx-island", name)
}
/// Return the island metadata attribute name without spelling it in product tests.
/// req: test/001 req: dx/006
pub fn island_attribute_name() -> &'static str {
"data-hemx-island"
}
/// Return the runtime island event name for an authoring island name.
/// req: test/001 req: dx/006
pub fn island_event_name(name: &str) -> String {
format!("hemx:island-{name}")
}
/// Return an SSE enhancement marker without spelling framework metadata in tests.
/// req: test/001 req: dx/006
pub fn sse_endpoint_marker(path: &str) -> String {
format!("data-hemx-sse=\"{path}\"")
}
/// Return the island snapshot marker without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn island_snapshot_marker() -> &'static str {
"data-island-snapshot="
}
/// Build a selector for island readouts without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn island_readout_selector() -> &'static str {
"[data-island-readout]"
}
/// Build browser-driver JavaScript for injecting a synthetic island probe.
///
/// This lets product tests exercise the island bridge without spelling hemx island
/// metadata attributes in the test body. req: test/001 req: dx/006
pub fn island_probe_script(
element_id: &str,
island_name: &str,
snapshot: &str,
event_detail: &str,
) -> String {
format!(
r#"
const root = arguments[0];
const islandName = {island_name};
const island = document.createElement('article');
island.id = {element_id};
island.setAttribute('data-hemx-island', islandName);
island.setAttribute('data-island-snapshot', {snapshot});
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 16;
island.appendChild(canvas);
const readout = document.createElement('p');
readout.setAttribute('data-island-readout', '');
readout.textContent = 'waiting';
island.appendChild(readout);
root.appendChild(island);
setTimeout(() => {{
root.dispatchEvent(new CustomEvent('hemx:island-' + islandName, {{ bubbles: true, detail: {event_detail} }}));
}}, 25);
return true;
"#,
element_id = js_string(element_id),
island_name = js_string(island_name),
snapshot = js_string(snapshot),
event_detail = js_string(event_detail),
)
}
/// Build a scoped selector for island readouts without spelling island metadata in tests.
/// req: test/001 req: dx/006
pub fn scoped_island_readout_selector(scope_selector: &str) -> String {
format!("{scope_selector} {}", island_readout_selector())
}
fn assert_simple_selector_part(value: &str, label: &str) {
assert!(
!value.is_empty()
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-'),
"{label} selector part must contain one or more ascii alphanumerics or '-'"
);
}
fn runtime_resource_attr(kind: ResourceKind) -> &'static str {
match kind {
ResourceKind::Slot => "data-sid",
ResourceKind::Atom => "data-aid",
ResourceKind::Handle => "data-hid",
ResourceKind::Form => "data-fid",
}
}
fn runtime_attr_marker(name: &str, value: &str) -> String {
format!(r#"{name}="{value}""#)
}
fn attr_selector(name: &str, value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!(r#"[{name}="{escaped}"]"#)
}
fn js_string(value: &str) -> String {
let mut escaped = String::from("\"");
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
ch => escaped.push(ch),
}
}
escaped.push('"');
escaped
}
fn form_pair(name: &str, value: &str) -> String {
format!("{}={}", form_encode(name), form_encode(value))
}
@@ -529,13 +174,11 @@ impl EffectInspector {
}
/// Assert against the same generated target object application handlers use.
/// req: test/001 req: dx/006
pub fn has_target(&self, target: impl GeneratedTarget) -> bool {
self.has_resource(target.__hemx_resource_id())
}
/// Check that a generated target receives a text update, without matching raw effects.
/// req: test/001 req: dx/006
pub fn updates_text(&self, target: impl GeneratedTarget) -> bool {
self.has_text_update_containing(target.__hemx_resource_id(), "")
}
@@ -544,13 +187,11 @@ impl EffectInspector {
///
/// This associates the payload condition with the intended target, unlike a separate global
/// [`Self::payload_contains`] check that can accidentally match another operation.
/// req: test/001 req: test/018 req: dx/006
pub fn updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
self.has_text_update_containing(target.__hemx_resource_id(), needle)
}
/// Assert that a generated target receives a text update containing a fragment.
/// req: test/001 req: test/018 req: dx/006
#[track_caller]
pub fn assert_updates_text_containing(&self, target: impl GeneratedTarget, needle: &str) {
let resource = target.__hemx_resource_id();
@@ -565,31 +206,25 @@ impl EffectInspector {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Text(text),
} if target.resource == resource && text.contains(needle)
Effect::Patch { target, html, .. }
if target.resource == resource && html.as_str().contains(needle)
)
})
}
/// Assert that a generated target receives an HTML update, without matching raw effects.
/// req: test/001 req: dx/006
pub fn updates_html(&self, target: impl GeneratedTarget) -> bool {
let resource = target.__hemx_resource_id();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Html(_),
} if target.resource == resource
Effect::Patch { target, .. } | Effect::Insert { target, .. }
if target.resource == resource
)
})
}
/// Check that a generated target receives an HTML update containing text.
/// req: test/001 req: dx/006
pub fn updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) -> bool {
self.has_html_update_containing(target.__hemx_resource_id(), needle)
}
@@ -598,7 +233,6 @@ impl EffectInspector {
///
/// Unlike wrapping [`Self::updates_html_containing`] in `assert!`, failures include the
/// expected resource and payload fragment together with every actual effect operation.
/// req: test/001 req: test/018 req: dx/006
#[track_caller]
pub fn assert_updates_html_containing(&self, target: impl GeneratedTarget, needle: &str) {
let resource = target.__hemx_resource_id();
@@ -613,16 +247,84 @@ impl EffectInspector {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Html(html),
} if target.resource == resource && html.contains(needle)
Effect::Patch { target, html, .. } | Effect::Insert { target, html, .. }
if target.resource == resource && html.as_str().contains(needle)
)
})
}
/// Parse the single HTML effect for a generated target as a complete document.
///
/// Returns an error when no HTML operation targets the resource or when more
/// than one operation would make the selected payload ambiguous.
pub fn target_html_document(
&self,
target: impl GeneratedTarget,
) -> Result<HtmlInspector, HtmlInspectionError> {
let resource = target.__hemx_resource_id();
let (html, operation) = self.single_target_html(resource)?;
Ok(HtmlInspector::document(
html.to_owned(),
format!("{operation} HTML effect for generated target {resource:?}"),
))
}
/// Parse the single HTML effect for a generated target as a fragment.
///
/// `Put`, `Insert`, and `Prepend` HTML payloads are supported. The parser is
/// kept internal; the returned inspector and all selected elements own their
/// observable data.
pub fn target_html_fragment(
&self,
target: impl GeneratedTarget,
) -> Result<HtmlInspector, HtmlInspectionError> {
let resource = target.__hemx_resource_id();
let (html, operation) = self.single_target_html(resource)?;
Ok(HtmlInspector::fragment(
html.to_owned(),
format!("{operation} HTML effect for generated target {resource:?}"),
))
}
fn single_target_html(
&self,
resource: ResourceId,
) -> Result<(&str, &'static str), HtmlInspectionError> {
let target_effects = self
.batch
.ops
.iter()
.filter(|effect| op_targets_resource(effect, resource))
.collect::<Vec<_>>();
let html_effects = target_effects
.iter()
.filter_map(|effect| match effect {
Effect::Patch { html, .. } => Some((html.as_str(), "Patch")),
Effect::Insert { html, .. } => Some((html.as_str(), "Insert")),
_ => None,
})
.collect::<Vec<_>>();
match html_effects.as_slice() {
[only] => Ok(*only),
[] => {
let actual = if target_effects.is_empty() {
format!("all effects: {:#?}", self.batch.ops)
} else {
format!("effects for target: {target_effects:#?}")
};
Err(HtmlInspectionError::new(format!(
"expected one HTML effect for generated target {resource:?}, but found none; {actual}"
)))
}
many => Err(HtmlInspectionError::new(format!(
"expected one HTML effect for generated target {resource:?}, but found {} and cannot choose a document or fragment payload; effects for target: {target_effects:#?}",
many.len()
))),
}
}
/// Assert that a keyed generated target is replaced with HTML containing text.
/// req: test/001 req: dx/006
pub fn replaces_keyed_html_containing(
&self,
target: impl GeneratedTarget,
@@ -634,16 +336,13 @@ impl EffectInspector {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Put {
target,
payload: Payload::Html(html),
} if target.resource == resource && target.scope == scope && html.contains(needle)
Effect::Patch { target, html, .. }
if target.resource == resource && target.scope == scope && html.as_str().contains(needle)
)
})
}
/// Assert that a keyed generated target appends HTML containing text.
/// req: test/001 req: dx/006
pub fn inserts_html_containing(
&self,
target: impl GeneratedTarget,
@@ -651,52 +350,42 @@ impl EffectInspector {
needle: &str,
) -> bool {
let resource = target.__hemx_resource_id();
let key = key.to_string();
let key_marker = format!("data-hemx-key=\"{}\"", key.to_string());
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Insert {
target,
key: actual_key,
payload: Payload::Html(html),
} if target.resource == resource && actual_key == &key && html.contains(needle)
Effect::Insert { target, html, .. }
if target.resource == resource
&& html.as_str().contains(&key_marker)
&& html.as_str().contains(needle)
)
})
}
/// Assert that a keyed generated target removes a key.
/// req: test/001 req: dx/006
pub fn removes_key(&self, target: impl GeneratedTarget, key: impl ToString) -> bool {
let resource = target.__hemx_resource_id();
let key = key.to_string();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Remove {
target,
key: Some(actual_key),
} if target.resource == resource && actual_key == &key
Effect::Remove { target }
if target.resource == resource && target.scope == Some(ScopeKey::KeyValue(key.clone()))
)
})
}
/// Assert that the batch requests a push navigation to a URL.
/// req: test/001 req: dx/006
pub fn pushes_to(&self, url: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Navigate {
url: actual_url,
mode: NavigateMode::Push,
..
} if actual_url == url
Effect::Visit { url: actual_url, .. } if actual_url == url
)
})
}
/// Assert that any payload or URL contains text, without matching raw effects.
/// req: test/001 req: dx/006
pub fn payload_contains(&self, needle: &str) -> bool {
self.batch
.ops
@@ -705,7 +394,6 @@ impl EffectInspector {
}
/// Assert that no payload or URL contains text, without matching raw effects.
/// req: test/001 req: dx/006
pub fn payload_excludes(&self, needle: &str) -> bool {
self.batch
.ops
@@ -714,13 +402,11 @@ impl EffectInspector {
}
/// Assert that generated keyed-row metadata for a key is absent from payloads.
/// req: test/001 req: dx/006
pub fn payload_excludes_key(&self, key: impl ToString) -> bool {
self.payload_excludes(&format!("data-key=\"{}\"", key.to_string()))
}
/// Return HTML for a generated target containing text, without exposing raw payloads.
/// req: test/001 req: dx/006
pub fn target_html_containing(
&self,
target: impl GeneratedTarget,
@@ -728,48 +414,38 @@ impl EffectInspector {
) -> Option<&str> {
let resource = target.__hemx_resource_id();
self.batch.ops.iter().find_map(|op| match op {
Effect::Put {
target,
payload: Payload::Html(html),
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
Effect::Insert {
target,
payload: Payload::Html(html),
..
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
Effect::Prepend {
target,
payload: Payload::Html(html),
..
} if target.resource == resource && html.contains(needle) => Some(html.as_str()),
Effect::Patch { target, html, .. }
if target.resource == resource && html.as_str().contains(needle) =>
{
Some(html.as_str())
}
Effect::Insert { target, html, .. }
if target.resource == resource && html.as_str().contains(needle) =>
{
Some(html.as_str())
}
_ => None,
})
}
/// Assert that a named generated event is emitted with the exact payload.
/// req: test/001 req: dx/006
pub fn emits(&self, name: &str, payload: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Emit {
name: actual_name,
payload: actual_payload,
} if actual_name == name && actual_payload == payload
Effect::Dispatch { event: actual_name, payload: actual_payload }
if actual_name == name && actual_payload.as_slice() == payload.as_bytes()
)
})
}
/// Assert that a named generated event payload contains text.
/// req: test/001 req: dx/006
pub fn emits_containing(&self, name: &str, needle: &str) -> bool {
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Emit {
name: actual_name,
payload,
} if actual_name == name && payload.contains(needle)
Effect::Dispatch { event: actual_name, payload }
if actual_name == name && String::from_utf8_lossy(payload).contains(needle)
)
})
}
@@ -796,58 +472,40 @@ impl EffectInspector {
pub fn has_form<T>(&self, form: Form<T>) -> bool {
self.has_resource(form.id())
}
/// Assert that a generated form is reset/cleared without matching raw events in tests.
/// req: test/001 req: dx/006
pub fn resets_form<T>(&self, form: Form<T>) -> bool {
let form_id = form.id().id.to_string();
self.batch.ops.iter().any(|op| {
matches!(
op,
Effect::Emit { name, payload }
if name == "hemx:form-reset" && payload == &form_id
)
})
}
}
fn effect_payload_contains(op: &Effect, needle: &str) -> bool {
match op {
Effect::Put { payload, .. }
| Effect::Insert { payload, .. }
| Effect::Prepend { payload, .. } => payload_value(payload).contains(needle),
Effect::Emit { payload, .. } => payload.contains(needle),
Effect::Navigate { url, .. } => url.contains(needle),
Effect::Remove { .. } | Effect::Move { .. } | Effect::Focus { .. } => false,
}
}
fn payload_value(payload: &Payload) -> &str {
match payload {
Payload::Text(value) | Payload::Html(value) => value,
Effect::Patch { html, .. } | Effect::Insert { html, .. } => html.as_str().contains(needle),
Effect::Dispatch { payload, .. } => String::from_utf8_lossy(payload).contains(needle),
Effect::Visit { url, .. } => url.contains(needle),
Effect::Remove { .. }
| Effect::Move { .. }
| Effect::Focus { .. }
| Effect::Scroll { .. } => false,
}
}
fn op_targets_resource(op: &Effect, resource: ResourceId) -> bool {
match op {
Effect::Put { target, .. }
Effect::Patch { target, .. }
| Effect::Insert { target, .. }
| Effect::Prepend { target, .. }
| Effect::Remove { target, .. }
| Effect::Remove { target }
| Effect::Move { target, .. }
| Effect::Focus { target } => target.resource == resource,
Effect::Navigate { .. } | Effect::Emit { .. } => false,
| Effect::Focus { target, .. }
| Effect::Scroll { target, .. } => target.resource == resource,
Effect::Visit { .. } | Effect::Dispatch { .. } => false,
}
}
fn op_targets_ref(op: &Effect, wanted: &ResourceRef) -> bool {
match op {
Effect::Put { target, .. }
Effect::Patch { target, .. }
| Effect::Insert { target, .. }
| Effect::Prepend { target, .. }
| Effect::Remove { target, .. }
| Effect::Remove { target }
| Effect::Move { target, .. }
| Effect::Focus { target } => target == wanted,
Effect::Navigate { .. } | Effect::Emit { .. } => false,
| Effect::Focus { target, .. }
| Effect::Scroll { target, .. } => target == wanted,
Effect::Visit { .. } | Effect::Dispatch { .. } => false,
}
}
+610
View File
@@ -0,0 +1,610 @@
use std::collections::VecDeque;
use std::error::Error;
use std::ffi::OsStr;
use std::fmt;
use std::io::{self, Read, Write};
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(25);
const DEFAULT_OUTPUT_LIMIT: usize = 64 * 1024;
/// Build and start a child process with an explicit readiness contract.
pub struct TestProcessBuilder {
command: Command,
label: String,
readiness: Option<Readiness>,
timeout: Duration,
poll_interval: Duration,
output_limit: usize,
}
impl fmt::Debug for TestProcessBuilder {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TestProcessBuilder")
.field("command", &self.command)
.field("label", &self.label)
.field("readiness", &self.readiness)
.field("timeout", &self.timeout)
.field("poll_interval", &self.poll_interval)
.field("output_limit", &self.output_limit)
.finish()
}
}
impl TestProcessBuilder {
/// Create a builder around a real process command.
pub fn new(command: Command) -> Self {
let label = command.get_program().to_string_lossy().into_owned();
Self {
command,
label,
readiness: None,
timeout: DEFAULT_TIMEOUT,
poll_interval: DEFAULT_POLL_INTERVAL,
output_limit: DEFAULT_OUTPUT_LIMIT,
}
}
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn arg(mut self, argument: impl AsRef<OsStr>) -> Self {
self.command.arg(argument);
self
}
pub fn args<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.command.args(arguments);
self
}
pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
self.command.env(key, value);
self
}
/// Wait until a TCP connection can be established.
///
/// A listener already occupying the address also satisfies this probe. Use
/// [`Self::http`] when readiness must identify an application endpoint.
pub fn tcp(mut self, address: impl Into<String>) -> Self {
self.readiness = Some(Readiness::Tcp {
address: address.into(),
});
self
}
/// Wait until a plain HTTP endpoint returns a status from 200 through 399.
pub fn http(mut self, address: impl Into<String>, path: impl Into<String>) -> Self {
self.readiness = Some(Readiness::Http {
address: address.into(),
path: path.into(),
});
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn poll_interval(mut self, interval: Duration) -> Self {
self.poll_interval = interval;
self
}
/// Bound retained output per stream. Readers continue draining after the
/// limit is reached so a noisy child cannot deadlock on a full pipe.
pub fn output_limit(mut self, bytes: usize) -> Self {
self.output_limit = bytes;
self
}
/// Spawn the process and wait for its readiness contract.
pub fn start(mut self) -> Result<TestProcess, ProcessError> {
if self.poll_interval.is_zero() {
return Err(ProcessError::configuration(
&self.label,
"poll interval must be greater than zero",
));
}
let readiness = self.readiness.ok_or_else(|| {
ProcessError::configuration(&self.label, "choose TCP or HTTP readiness before start")
})?;
let readiness = readiness.resolve(&self.label)?;
let readiness_description = readiness.description();
self.command.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = self.command.spawn().map_err(|source| ProcessError::Spawn {
label: self.label.clone(),
source,
})?;
let stdout = child
.stdout
.take()
.map(|stream| CapturedOutput::spawn(stream, self.output_limit));
let stderr = child
.stderr
.take()
.map(|stream| CapturedOutput::spawn(stream, self.output_limit));
let mut process = TestProcess {
child: Some(child),
label: self.label.clone(),
stdout,
stderr,
exit_status: None,
};
let started = Instant::now();
let deadline = started + self.timeout;
let mut attempts = 0usize;
loop {
attempts += 1;
let remaining = deadline.saturating_duration_since(Instant::now());
let probe_timeout = remaining.min(self.poll_interval);
if readiness.probe(probe_timeout) {
return Ok(process);
}
if let Some(status) = process.try_wait().map_err(|source| ProcessError::Monitor {
label: self.label.clone(),
source,
})? {
process.finish_capture();
return Err(ProcessError::EarlyExit {
status,
failure: Box::new(ProcessFailure {
label: self.label,
readiness: readiness_description,
attempts,
stdout: process.stdout(),
stderr: process.stderr(),
}),
});
}
if Instant::now() >= deadline {
let cleanup_error = process.shutdown().err().map(|error| error.to_string());
return Err(ProcessError::TimedOut {
timeout: self.timeout,
failure: Box::new(ProcessFailure {
label: self.label,
readiness: readiness_description,
attempts,
stdout: process.stdout(),
stderr: process.stderr(),
}),
cleanup_error,
});
}
thread::sleep(self.poll_interval.min(remaining));
}
}
}
/// A real child process owned by an integration test.
///
/// Explicit shutdown and `Drop` are idempotent. Both kill a running child, wait
/// for it, drain captured output, and release the process handle.
pub struct TestProcess {
child: Option<Child>,
label: String,
stdout: Option<CapturedOutput>,
stderr: Option<CapturedOutput>,
exit_status: Option<ExitStatus>,
}
impl fmt::Debug for TestProcess {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TestProcess")
.field("label", &self.label)
.field("id", &self.id())
.field("exit_status", &self.exit_status)
.finish_non_exhaustive()
}
}
impl TestProcess {
pub fn builder(command: Command) -> TestProcessBuilder {
TestProcessBuilder::new(command)
}
/// Compatibility entry point for TCP-ready processes.
pub fn start(
command: Command,
label: impl Into<String>,
address: &str,
timeout: Duration,
) -> io::Result<Self> {
Self::builder(command)
.label(label)
.tcp(address)
.timeout(timeout)
.start()
.map_err(ProcessError::into_io)
}
pub fn id(&self) -> Option<u32> {
self.child.as_ref().map(Child::id)
}
pub fn exit_status(&self) -> Option<ExitStatus> {
self.exit_status
}
/// Return the bounded, currently captured standard output.
pub fn stdout(&self) -> String {
self.stdout
.as_ref()
.map(CapturedOutput::snapshot)
.unwrap_or_default()
}
/// Return the bounded, currently captured standard error.
pub fn stderr(&self) -> String {
self.stderr
.as_ref()
.map(CapturedOutput::snapshot)
.unwrap_or_default()
}
pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
if let Some(status) = self.exit_status {
return Ok(Some(status));
}
let Some(child) = self.child.as_mut() else {
return Ok(self.exit_status);
};
let status = child.try_wait()?;
if let Some(status) = status {
self.exit_status = Some(status);
}
Ok(status)
}
/// Kill a running child, wait for it, and drain output. Calling this more
/// than once is harmless.
pub fn shutdown(&mut self) -> io::Result<()> {
if let Some(mut child) = self.child.take() {
let status = match child.try_wait()? {
Some(status) => status,
None => {
if let Err(error) = child.kill() {
if error.kind() != io::ErrorKind::InvalidInput {
return Err(error);
}
}
child.wait()?
}
};
self.exit_status = Some(status);
}
self.finish_capture();
Ok(())
}
fn finish_capture(&mut self) {
if let Some(capture) = self.stdout.as_mut() {
capture.finish();
}
if let Some(capture) = self.stderr.as_mut() {
capture.finish();
}
}
}
impl Drop for TestProcess {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
/// Captured context for an early exit or readiness timeout.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProcessFailure {
pub label: String,
pub readiness: String,
pub attempts: usize,
pub stdout: String,
pub stderr: String,
}
/// Failure to configure, start, observe, or clean up a test process.
#[derive(Debug)]
pub enum ProcessError {
Configuration {
label: String,
message: String,
},
Spawn {
label: String,
source: io::Error,
},
Monitor {
label: String,
source: io::Error,
},
EarlyExit {
status: ExitStatus,
failure: Box<ProcessFailure>,
},
TimedOut {
timeout: Duration,
failure: Box<ProcessFailure>,
cleanup_error: Option<String>,
},
}
impl ProcessError {
fn configuration(label: &str, message: &str) -> Self {
Self::Configuration {
label: label.to_owned(),
message: message.to_owned(),
}
}
fn into_io(self) -> io::Error {
let kind = match &self {
Self::Spawn { source, .. } | Self::Monitor { source, .. } => source.kind(),
Self::TimedOut { .. } => io::ErrorKind::TimedOut,
Self::Configuration { .. } => io::ErrorKind::InvalidInput,
Self::EarlyExit { .. } => io::ErrorKind::Other,
};
io::Error::new(kind, self)
}
}
impl fmt::Display for ProcessError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Configuration { label, message } => {
write!(formatter, "invalid readiness for {label}: {message}")
}
Self::Spawn { label, source } => write!(formatter, "failed to spawn {label}: {source}"),
Self::Monitor { label, source } => {
write!(formatter, "failed to observe {label}: {source}")
}
Self::EarlyExit { status, failure } => write!(
formatter,
"{} exited with {status} before {} after {} readiness attempts{}{}",
failure.label,
failure.readiness,
failure.attempts,
output_section("stdout", &failure.stdout),
output_section("stderr", &failure.stderr)
),
Self::TimedOut {
timeout,
failure,
cleanup_error,
} => write!(
formatter,
"timed out after {timeout:?} waiting for {} to satisfy {} after {} readiness attempts{}{}{}",
failure.label,
failure.readiness,
failure.attempts,
output_section("stdout", &failure.stdout),
output_section("stderr", &failure.stderr),
cleanup_error
.as_deref()
.map(|error| format!("\ncleanup error:\n{error}"))
.unwrap_or_default()
),
}
}
}
impl Error for ProcessError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Spawn { source, .. } | Self::Monitor { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Clone, Debug)]
enum Readiness {
Tcp { address: String },
Http { address: String, path: String },
}
impl Readiness {
fn resolve(self, label: &str) -> Result<ResolvedReadiness, ProcessError> {
match self {
Self::Tcp { address } => Ok(ResolvedReadiness::Tcp {
socket: resolve_address(label, &address)?,
address,
}),
Self::Http { address, path } => {
if !path.starts_with('/') {
return Err(ProcessError::configuration(
label,
"HTTP readiness path must start with '/'",
));
}
Ok(ResolvedReadiness::Http {
socket: resolve_address(label, &address)?,
address,
path,
})
}
}
}
}
#[derive(Clone, Debug)]
enum ResolvedReadiness {
Tcp {
address: String,
socket: SocketAddr,
},
Http {
address: String,
socket: SocketAddr,
path: String,
},
}
impl ResolvedReadiness {
fn description(&self) -> String {
match self {
Self::Tcp { address, .. } => format!("TCP readiness on {address}"),
Self::Http { address, path, .. } => {
format!("HTTP readiness at http://{address}{path}")
}
}
}
fn probe(&self, timeout: Duration) -> bool {
if timeout.is_zero() {
return false;
}
match self {
Self::Tcp { socket, .. } => TcpStream::connect_timeout(socket, timeout).is_ok(),
Self::Http {
address,
socket,
path,
} => probe_http(*socket, address, path, timeout),
}
}
}
fn resolve_address(label: &str, address: &str) -> Result<SocketAddr, ProcessError> {
address
.to_socket_addrs()
.map_err(|error| {
ProcessError::configuration(label, &format!("could not resolve {address:?}: {error}"))
})?
.next()
.ok_or_else(|| ProcessError::configuration(label, &format!("{address:?} resolved empty")))
}
fn probe_http(socket: SocketAddr, host: &str, path: &str, timeout: Duration) -> bool {
let Ok(mut stream) = TcpStream::connect_timeout(&socket, timeout) else {
return false;
};
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
let request = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_err() {
return false;
}
let mut response = Vec::with_capacity(128);
while response.len() < 128 && !response.contains(&b'\n') {
let mut chunk = [0_u8; 32];
let Ok(read) = stream.read(&mut chunk) else {
return false;
};
if read == 0 {
break;
}
response.extend_from_slice(&chunk[..read]);
}
let Ok(status_line) = std::str::from_utf8(&response) else {
return false;
};
status_line
.split_whitespace()
.nth(1)
.and_then(|status| status.parse::<u16>().ok())
.is_some_and(|status| (200..400).contains(&status))
}
struct CapturedOutput {
buffer: Arc<Mutex<CapturedBuffer>>,
reader: Option<JoinHandle<()>>,
}
impl CapturedOutput {
fn spawn(mut stream: impl Read + Send + 'static, limit: usize) -> Self {
let buffer = Arc::new(Mutex::new(CapturedBuffer::new(limit)));
let reader_buffer = Arc::clone(&buffer);
let reader = thread::spawn(move || {
let mut chunk = [0_u8; 4096];
loop {
match stream.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(read) => reader_buffer
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(&chunk[..read]),
}
}
});
Self {
buffer,
reader: Some(reader),
}
}
fn snapshot(&self) -> String {
self.buffer
.lock()
.unwrap_or_else(|error| error.into_inner())
.render()
}
fn finish(&mut self) {
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
}
struct CapturedBuffer {
bytes: VecDeque<u8>,
limit: usize,
omitted: usize,
}
impl CapturedBuffer {
fn new(limit: usize) -> Self {
Self {
bytes: VecDeque::with_capacity(limit.min(4096)),
limit,
omitted: 0,
}
}
fn push(&mut self, bytes: &[u8]) {
self.bytes.extend(bytes);
while self.bytes.len() > self.limit {
self.bytes.pop_front();
self.omitted += 1;
}
}
fn render(&self) -> String {
let bytes = self.bytes.iter().copied().collect::<Vec<_>>();
let output = String::from_utf8_lossy(&bytes);
if self.omitted == 0 {
output.into_owned()
} else {
format!("<{} earlier bytes omitted>\n{output}", self.omitted)
}
}
}
fn output_section(name: &str, output: &str) -> String {
if output.is_empty() {
String::new()
} else {
format!("\n{name}:\n{output}")
}
}
+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));
}
}