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
+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}")
}
}