Implement closed typed effect path
This commit is contained in:
@@ -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 {}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user