Implement closed typed effect path
This commit is contained in:
+19
-3
@@ -1,10 +1,26 @@
|
||||
[package]
|
||||
name = "hemx-test"
|
||||
version.workspace = true
|
||||
version = "0.4.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
readme = "README.md"
|
||||
description = "Test support for Hemx applications"
|
||||
keywords = ["hypermedia", "testing", "web"]
|
||||
categories = ["development-tools::testing", "web-programming"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
axum = ["dep:axum", "dep:tower"]
|
||||
|
||||
[dependencies]
|
||||
hemx-core = { path = "../hemx-core" }
|
||||
axum = { version = "0.8.8", optional = true, default-features = false }
|
||||
hemx-core = { version = "0.3.0", path = "../hemx-core" }
|
||||
scraper = "0.25.0"
|
||||
tower = { version = "0.5.2", optional = true, features = ["util"] }
|
||||
|
||||
[dev-dependencies]
|
||||
hemx-build = { path = "../hemx-build" }
|
||||
hemx-axum = { version = "0.3.0", path = "../hemx-axum" }
|
||||
hemx-build = { version = "0.3.0", path = "../hemx-build" }
|
||||
tokio = { version = "1.48.0", features = ["macros", "rt"] }
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Thomas Hain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,214 @@
|
||||
# hemx-test
|
||||
|
||||
Test support for Hemx applications and generated resources.
|
||||
|
||||
`hemx-test` keeps assertions at the same typed boundary as application code. Use
|
||||
an `EffectInspector` with generated targets instead of copying resource IDs or
|
||||
matching raw wire operations.
|
||||
|
||||
## Choose the owning boundary
|
||||
|
||||
| Behavior under test | Smallest authoritative proof |
|
||||
| --- | --- |
|
||||
| Domain invariants and state transitions | Ordinary Rust unit tests; no Hemx harness |
|
||||
| Handler output and generated-target effects | `run`, `run_async`, `run_result`, or `run_async_result` plus `EffectInspector` |
|
||||
| Static document or effect-fragment structure | `HtmlInspector`, reached directly or through `target_html_document` / `target_html_fragment` |
|
||||
| Axum extraction, middleware, status, headers, and response body | The opt-in `hemx_test::axum` router harness |
|
||||
| Real process startup, readiness, sockets, logs, and cleanup | `TestProcess::builder` |
|
||||
| JavaScript, focus, history, layout, or runtime reconciliation | A focused real-browser test outside `hemx-test` |
|
||||
|
||||
## Effects and structural HTML
|
||||
|
||||
```rust
|
||||
use hemx_core::{GeneratedTarget, SafeHtml, Slot};
|
||||
# use hemx_core::{Effect, ResourceId};
|
||||
# #[derive(Clone, Copy)]
|
||||
# struct GeneratedTodos(Slot<()>);
|
||||
# impl GeneratedTarget for GeneratedTodos {
|
||||
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
|
||||
# }
|
||||
# impl GeneratedTodos {
|
||||
# fn html(self, value: SafeHtml) -> Effect { self.0.html(value) }
|
||||
# }
|
||||
|
||||
// Application build output supplies generated target types like this one.
|
||||
let todos = GeneratedTodos(Slot::new(7));
|
||||
let effect = todos.html(SafeHtml::trusted(
|
||||
r#"<ul><li class="todo" data-state="open">Write tests</li></ul>"#,
|
||||
));
|
||||
|
||||
let inspected = hemx_test::inspect(effect);
|
||||
assert!(inspected.updates_html(todos));
|
||||
|
||||
let html = inspected.target_html_fragment(todos)?;
|
||||
html.assert_count("li.todo", 1);
|
||||
html.assert_text("li.todo", "Write tests");
|
||||
html.assert_attribute("li.todo", "data-state", "open");
|
||||
# Ok::<(), hemx_test::HtmlInspectionError>(())
|
||||
```
|
||||
|
||||
Complete server-rendered pages can be inspected directly:
|
||||
|
||||
```rust
|
||||
let page = hemx_test::inspect_html_document(
|
||||
"<!doctype html><html><body><main id=app data-sid=7>Ready</main></body></html>",
|
||||
);
|
||||
page.assert_text("main#app", "Ready");
|
||||
# use hemx_core::{GeneratedTarget, ResourceId, Slot};
|
||||
# #[derive(Clone, Copy)]
|
||||
# struct GeneratedApp(Slot<()>);
|
||||
# impl GeneratedTarget for GeneratedApp {
|
||||
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
|
||||
# }
|
||||
page.assert_target(GeneratedApp(Slot::new(7)));
|
||||
```
|
||||
|
||||
Structural inspection is backed internally by an HTML parser, but its types are
|
||||
not part of the public API. `HtmlInspector`, `HtmlSelection`, and `HtmlElement`
|
||||
own the observable source and selected data. Structural inspection does not run
|
||||
JavaScript or prove browser-owned behavior such as focus, history, layout, or
|
||||
runtime reconciliation.
|
||||
|
||||
A target inspector requires exactly one `Put`, `Insert`, or `Prepend` HTML
|
||||
payload for that generated target. Missing, non-HTML, and ambiguous target
|
||||
effects return `HtmlInspectionError` with the relevant effects in the
|
||||
diagnostic.
|
||||
|
||||
## Handlers
|
||||
|
||||
`run` and `run_async` invoke typed synchronous and asynchronous handlers and
|
||||
return the same `EffectInspector`:
|
||||
|
||||
```rust
|
||||
use hemx_core::{Effect, GeneratedTarget, ResourceId, Slot};
|
||||
# #[derive(Clone, Copy)]
|
||||
# struct GeneratedCount(Slot<u32>);
|
||||
# impl GeneratedTarget for GeneratedCount {
|
||||
# fn __hemx_resource_id(self) -> ResourceId { self.0.id() }
|
||||
# }
|
||||
|
||||
async fn load_count(value: u32) -> Effect {
|
||||
Slot::<u32>::new(1).text(value)
|
||||
}
|
||||
|
||||
# async fn example() {
|
||||
let count = GeneratedCount(Slot::new(1));
|
||||
let inspected = hemx_test::run_async(load_count, 42).await;
|
||||
assert!(inspected.updates_text_containing(count, "42"));
|
||||
# }
|
||||
```
|
||||
|
||||
Fallible handlers use `run_result` or `run_async_result`. Their concrete error
|
||||
is returned unchanged and is never converted into an empty or success-looking
|
||||
effect batch:
|
||||
|
||||
```rust
|
||||
use hemx_core::{Effect, Slot};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct Rejected;
|
||||
|
||||
async fn save(accepted: bool) -> Result<Effect, Rejected> {
|
||||
accepted
|
||||
.then(|| Slot::<()>::new(1).text("saved"))
|
||||
.ok_or(Rejected)
|
||||
}
|
||||
|
||||
# async fn example() {
|
||||
let error = hemx_test::run_async_result(save, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error, Rejected);
|
||||
# }
|
||||
```
|
||||
|
||||
`IntoEffect` conversion itself is infallible in the current public contract, so
|
||||
handler errors and inspected success effects remain distinct.
|
||||
|
||||
## Axum routers
|
||||
|
||||
Enable the `axum` feature to send owned requests through a real `axum::Router`.
|
||||
The response always preserves status, headers, and raw body bytes; parse it as a
|
||||
Hemx effect batch or structural HTML only when that is the response contract.
|
||||
|
||||
```rust
|
||||
# #[cfg(feature = "axum")]
|
||||
# async fn example() -> Result<(), hemx_test::axum::RouterTestError> {
|
||||
use axum::{response::Html, routing::get, Router};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
let app = Router::new().route(
|
||||
"/",
|
||||
get(|| async { Html("<main id=app>Ready</main>") }),
|
||||
);
|
||||
let response = hemx_test::axum::get("/").send(app).await?;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response.html_fragment()?.assert_text("main#app", "Ready");
|
||||
# Ok(())
|
||||
# }
|
||||
```
|
||||
|
||||
`post(...).form(handle, fields)` builds the URL-encoded interaction body from a
|
||||
typed Hemx handle. Authentication, CSRF, sessions, persistence, middleware, and
|
||||
test providers remain application-owned. This in-process harness does not prove
|
||||
real sockets, process startup, or browser behavior.
|
||||
|
||||
## Processes
|
||||
|
||||
Use `TestProcess` only when the process and socket lifecycle are part of the
|
||||
behavior under test. Readiness is explicit and bounded:
|
||||
|
||||
```rust,no_run
|
||||
use hemx_test::TestProcess;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
let process = TestProcess::builder(Command::new("target/debug/my-app"))
|
||||
.label("application server")
|
||||
.arg("serve")
|
||||
.env("APP_ADDR", "127.0.0.1:4100")
|
||||
.http("127.0.0.1:4100", "/health")
|
||||
.timeout(Duration::from_secs(5))
|
||||
.start()?;
|
||||
|
||||
assert!(process.id().is_some());
|
||||
# Ok::<(), hemx_test::ProcessError>(())
|
||||
```
|
||||
|
||||
TCP readiness proves only that something accepts the address; HTTP readiness
|
||||
requires a 2xx or 3xx response from the selected path. Startup errors include
|
||||
the process label, readiness attempts, exit status when available, and bounded
|
||||
stdout/stderr. Readers keep draining after the capture limit so noisy children
|
||||
do not deadlock. Explicit `shutdown` and `Drop` both kill, wait for, and reap a
|
||||
running child and are safe to call more than once.
|
||||
|
||||
`TestProcess::start` remains available as the compact compatibility entry point
|
||||
for TCP readiness. The harness intentionally does not reserve ports or claim
|
||||
that a reserve-then-bind handoff is atomic.
|
||||
|
||||
## Migrating selector helpers
|
||||
|
||||
The original `0.1.0` crate exposed app-specific CSS builders and island/browser
|
||||
probe helpers. They duplicated CSS syntax, made application structure look like
|
||||
a framework contract, and could not prove browser behavior. They are removed
|
||||
from the next release rather than preserved as a second testing vocabulary.
|
||||
|
||||
- Replace semantic selector builders such as `article_selector`,
|
||||
`class_selector`, and `nav_link_selector` with the CSS selector that expresses
|
||||
the application's own HTML contract in `HtmlInspector`.
|
||||
- Replace `assert_rendered_target` and `assert_rendered_handle` with
|
||||
`HtmlInspector::assert_target` and `HtmlInspector::assert_handle`; generated
|
||||
resources remain the assertion vocabulary and raw runtime IDs stay private.
|
||||
- Replace `target_selector`, `handle_selector`, and keyed selector builders with
|
||||
structural HTML assertions. Browser tests that genuinely need a selector
|
||||
should keep that selector in their browser-test adapter.
|
||||
- Replace island probe scripts, readout selectors, event-name helpers, and SSE
|
||||
marker strings with a focused real-browser journey; `hemx-test` does not
|
||||
emulate JavaScript or runtime behavior.
|
||||
|
||||
The generated-resource helpers for effect inspection and typed form bodies
|
||||
remain supported.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
#![cfg(feature = "axum")]
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, HeaderMap, HeaderValue, Method, Response, StatusCode};
|
||||
use axum::response::{Html, IntoResponse, Redirect};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use hemx_axum::{EffectResponse, InteractionForm, HEMX_CONTENT_TYPE};
|
||||
use hemx_core::{BuildFingerprint, Handle, Slot};
|
||||
|
||||
async fn effects() -> EffectResponse {
|
||||
EffectResponse::new(
|
||||
Slot::<()>::new(1).text("ready"),
|
||||
BuildFingerprint::from_parts(&[7]),
|
||||
)
|
||||
}
|
||||
|
||||
async fn document() -> Html<&'static str> {
|
||||
Html("<!doctype html><html><body><main id=app>Ready</main></body></html>")
|
||||
}
|
||||
|
||||
async fn fragment() -> Html<&'static str> {
|
||||
Html("<li data-state=open>Write tests</li>")
|
||||
}
|
||||
|
||||
async fn redirect() -> Redirect {
|
||||
Redirect::to("/next")
|
||||
}
|
||||
|
||||
async fn rejected() -> impl IntoResponse {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, "invalid title")
|
||||
}
|
||||
|
||||
async fn request_header(headers: HeaderMap) -> String {
|
||||
headers
|
||||
.get("x-test")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("missing")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
async fn malformed_wire() -> Response<Body> {
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, HEMX_CONTENT_TYPE)
|
||||
.body(Body::from(vec![0xff, 0x00, 0x01]))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn form(form: InteractionForm) -> String {
|
||||
format!("{}:{}", form.handle_id, form.value("title").unwrap_or(""))
|
||||
}
|
||||
|
||||
fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/effects", get(effects))
|
||||
.route("/document", get(document))
|
||||
.route("/fragment", get(fragment))
|
||||
.route("/redirect", get(redirect))
|
||||
.route("/rejected", get(rejected))
|
||||
.route("/request-header", get(request_header))
|
||||
.route("/malformed", get(malformed_wire))
|
||||
.route("/form", post(form))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inspects_effect_response_from_real_router() {
|
||||
let response = hemx_test::axum::get("/effects")
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.content_type(), Some(HEMX_CONTENT_TYPE));
|
||||
assert!(!response.body().is_empty());
|
||||
assert!(response
|
||||
.effects()
|
||||
.unwrap()
|
||||
.updates_text_containing(GeneratedSlot, "ready"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_status_headers_redirects_and_text_errors() {
|
||||
let request_header = hemx_test::axum::request(Method::GET, "/request-header")
|
||||
.header(
|
||||
"x-test".parse().unwrap(),
|
||||
HeaderValue::from_static("request"),
|
||||
)
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(request_header.text().unwrap(), "request");
|
||||
|
||||
let redirect = hemx_test::axum::get("/redirect")
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(redirect.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(redirect.headers()[header::LOCATION], "/next");
|
||||
assert!(redirect.body().is_empty());
|
||||
|
||||
let rejected = hemx_test::axum::get("/rejected")
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(rejected.text().unwrap(), "invalid title");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn structurally_inspects_document_and_fragment_responses() {
|
||||
let document = hemx_test::axum::get("/document")
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap()
|
||||
.html_document()
|
||||
.unwrap();
|
||||
document.assert_text("main#app", "Ready");
|
||||
|
||||
let fragment = hemx_test::axum::get("/fragment")
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap()
|
||||
.html_fragment()
|
||||
.unwrap();
|
||||
fragment.assert_attribute("li", "data-state", "open");
|
||||
fragment.assert_text("li", "Write tests");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builds_urlencoded_interaction_forms_from_typed_handles() {
|
||||
let response = hemx_test::axum::post("/form")
|
||||
.form(Handle::<()>::new(17), &[("title", "one & two")])
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.text().unwrap(), "17:one & two");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_unexpected_content_types_and_malformed_wire() {
|
||||
let html = hemx_test::axum::get("/document")
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap();
|
||||
let wrong_type = html.effects().unwrap_err().to_string();
|
||||
assert!(wrong_type.contains("application/hemx"), "{wrong_type}");
|
||||
assert!(wrong_type.contains("text/html"), "{wrong_type}");
|
||||
|
||||
let malformed = hemx_test::axum::get("/malformed")
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap()
|
||||
.effects()
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
malformed.contains("invalid Hemx effect response"),
|
||||
"{malformed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enforces_explicit_response_body_limit() {
|
||||
let error = hemx_test::axum::get("/document")
|
||||
.body_limit(8)
|
||||
.send(router())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("within 8 bytes"), "{error}");
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GeneratedSlot;
|
||||
|
||||
impl hemx_core::GeneratedTarget for GeneratedSlot {
|
||||
fn __hemx_resource_id(self) -> hemx_core::ResourceId {
|
||||
Slot::<()>::new(1).id()
|
||||
}
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn canonical_examples_do_not_author_browser_javascript() {
|
||||
// req: examples/005
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.components().any(|part| part.as_os_str() == "tests") {
|
||||
return;
|
||||
}
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains("<script") && !allowed_example_script(path, trimmed) {
|
||||
failures.push(format!(
|
||||
"{}:{}: only the hemx runtime or explicit opaque-island scripts are allowed",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
if contains_inline_event_handler(trimmed) {
|
||||
failures.push(format!(
|
||||
"{}:{}: inline on*= handler is not allowed",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
if trimmed.to_ascii_lowercase().contains("javascript:") {
|
||||
failures.push(format!(
|
||||
"{}:{}: javascript: URL is not allowed",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_examples_do_not_author_low_level_resource_plumbing() {
|
||||
// req: dx/002 req: ceremony/002 req: ceremony/004 req: examples/003
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let forbidden = [
|
||||
"ResourceId::new",
|
||||
".id().id",
|
||||
"include!(concat!",
|
||||
"OUT_DIR",
|
||||
"global_exports(",
|
||||
"EffectWriter",
|
||||
"RuntimeOpcode",
|
||||
"data-hid",
|
||||
"data-sid",
|
||||
"data-fid",
|
||||
"data-aid",
|
||||
"data-handle-id",
|
||||
"data-slot-id",
|
||||
"data-form-id",
|
||||
"data-atom-id",
|
||||
"::lower(include_str!",
|
||||
"lower_html(",
|
||||
"render_html(",
|
||||
"HandlerRegistry",
|
||||
".into_registry()",
|
||||
"InteractionForm",
|
||||
"register_handle(",
|
||||
"SafeHtml",
|
||||
"hemx::advanced",
|
||||
"advanced::slots",
|
||||
"KeyedSlot",
|
||||
"Slot<",
|
||||
"Effect::",
|
||||
"Payload::",
|
||||
"NavigateMode",
|
||||
"ScopeKey",
|
||||
"opcode",
|
||||
"wire format",
|
||||
"manual generated",
|
||||
"RenderSlotExt",
|
||||
"RenderKeyedSlotExt",
|
||||
".render(&",
|
||||
".html(ui::render",
|
||||
".html(hemx::render",
|
||||
".html(super::ui::render",
|
||||
"ui::put(",
|
||||
"ui::append(",
|
||||
"ui::replace(",
|
||||
"application/hemx",
|
||||
"EffectBatch",
|
||||
"__h=",
|
||||
"name=\"__h\"",
|
||||
"name='__h'",
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.components().any(|part| part.as_os_str() == "tests") {
|
||||
return;
|
||||
}
|
||||
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
|
||||
return;
|
||||
};
|
||||
if !matches!(ext, "rs" | "heml" | "html") {
|
||||
return;
|
||||
}
|
||||
let runtime_source = if ext == "rs" {
|
||||
text.split("#[cfg(test)]").next().unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
for (line_no, line) in runtime_source.lines().enumerate() {
|
||||
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||
failures.push(format!(
|
||||
"{}:{}: example runtime code must use generated resources, not `{token}`",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_prelude_does_not_export_low_level_primitives() {
|
||||
// req: public_api/005 req: dx/006
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let facade = std::fs::read_to_string(root.join("hemx/src/lib.rs")).unwrap();
|
||||
let prelude = facade
|
||||
.split("pub mod prelude {")
|
||||
.nth(1)
|
||||
.and_then(|tail| tail.split("\n}").next())
|
||||
.expect("hemx facade exposes prelude module");
|
||||
let exported = prelude
|
||||
.split(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
for token in [
|
||||
"SafeHtml",
|
||||
"Effect",
|
||||
"Slot",
|
||||
"KeyedSlot",
|
||||
"ResourceId",
|
||||
"EffectBatch",
|
||||
"BuildFingerprint",
|
||||
"Atom",
|
||||
"ComponentRef",
|
||||
"EventName",
|
||||
"GeneratedTarget",
|
||||
"Handle",
|
||||
"ParamName",
|
||||
"page",
|
||||
] {
|
||||
assert!(
|
||||
!exported.contains(&token),
|
||||
"hemx::prelude must not export low-level `{token}`"
|
||||
);
|
||||
}
|
||||
assert!(prelude.contains("Html"), "hemx::prelude should export Html");
|
||||
assert!(
|
||||
prelude.contains("IntoEffect"),
|
||||
"hemx::prelude should export IntoEffect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_example_docs_do_not_teach_low_level_plumbing() {
|
||||
// req: dx/002 req: dx/003 req: examples/003
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let forbidden = [
|
||||
"generated handles and slots",
|
||||
"generated hemx handles",
|
||||
"generated handle ids",
|
||||
"numeric handle ids",
|
||||
"runtime lowering",
|
||||
"application/hemx responses",
|
||||
"EffectBatch",
|
||||
"manual registry",
|
||||
"HandlerRegistry",
|
||||
".into_registry()",
|
||||
"InteractionForm",
|
||||
"register_handle(",
|
||||
"lower_html(",
|
||||
"render_html(",
|
||||
"SafeHtml",
|
||||
"KeyedSlot",
|
||||
"Slot<",
|
||||
"Effect::",
|
||||
"opcode",
|
||||
"wire format",
|
||||
"manual generated",
|
||||
"Effect::batch",
|
||||
"Effect::class",
|
||||
"Effect::move",
|
||||
"Effect::set",
|
||||
"Effect::broadcast",
|
||||
"Effect::ack",
|
||||
"SyncEffect::",
|
||||
"postcard DOM ops",
|
||||
"data-hid",
|
||||
"data-sid",
|
||||
".render(&",
|
||||
"addEventListener(",
|
||||
"querySelector",
|
||||
"querySelectorAll",
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md")
|
||||
|| is_advanced_boundary_doc(text)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||
failures.push(format!(
|
||||
"{}:{}: example docs must teach generated ergonomic APIs, not `{token}`",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_docs_do_not_show_runtime_metadata_as_authoring_contract() {
|
||||
// req: dx/006 req: misc/006
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let examples = root.join("examples");
|
||||
let forbidden = [
|
||||
"data-hid",
|
||||
"data-sid",
|
||||
"data-aid",
|
||||
"data-fid",
|
||||
"[data-hid",
|
||||
"[data-sid",
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
scan_examples(&examples, &mut |path, text| {
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
return;
|
||||
}
|
||||
for (line_no, line) in text.lines().enumerate() {
|
||||
if let Some(token) = forbidden.iter().find(|token| line.contains(*token)) {
|
||||
failures.push(format!(
|
||||
"{}:{}: example docs must describe generated authoring APIs, not runtime metadata `{token}`",
|
||||
path.display(),
|
||||
line_no + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_e2e_does_not_shortcut_product_interactions() {
|
||||
// req: examples/005
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let browser_e2e = root.join("examples/techdemo/tests/browser_e2e.rs");
|
||||
let source = std::fs::read_to_string(browser_e2e).unwrap();
|
||||
|
||||
assert!(
|
||||
!source.contains("fetch(\"/\""),
|
||||
"browser E2E must drive UI, not post directly"
|
||||
);
|
||||
assert!(
|
||||
!source.contains("applyBatch(buffer"),
|
||||
"browser E2E must not apply wire batches manually"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_examples_slot_targets_dispatch_to_component_put() {
|
||||
// req: runtime/005 req: examples/001 req: htmx_equivalents/001
|
||||
// Regression: slots named after a component (e.g. contact_card, editable_row)
|
||||
// must lower injected partials using that component's handle table, not the
|
||||
// containing page's handle table.
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let template_dir = root.join("examples/html_examples/templates");
|
||||
let out_dir = std::env::temp_dir().join(format!(
|
||||
"hemx-test-html-examples-slot-targets-{}",
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&out_dir);
|
||||
std::fs::create_dir_all(&out_dir).unwrap();
|
||||
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
hemx_build::app()
|
||||
.template_dir(&template_dir)
|
||||
.out_dir(&out_dir)
|
||||
.run()
|
||||
.expect("hemx-build should generate html_examples artifacts");
|
||||
|
||||
let generated =
|
||||
std::fs::read_to_string(out_dir.join("hemx.generated.rs")).unwrap_or_default();
|
||||
|
||||
for component in ["contact_card", "editable_row"] {
|
||||
let component_path = format!("super::super::{component}");
|
||||
let impl_header = format!("impl<T> SlotTarget<T, {component_path}::Component>");
|
||||
let put_call = format!("{component_path}::put(self.slot, view)");
|
||||
assert!(
|
||||
generated.contains(&impl_header),
|
||||
"gallery::targets should have a component-specific SlotTarget impl for `{component}`; missing `{impl_header}`"
|
||||
);
|
||||
assert!(
|
||||
generated.contains(&put_call),
|
||||
"gallery::targets::{component} put/replace should dispatch to `{component_path}::put`; missing `{put_call}`"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let _ = std::fs::remove_dir_all(&out_dir);
|
||||
result.unwrap();
|
||||
}
|
||||
|
||||
fn scan_examples(dir: &Path, visit: &mut impl FnMut(&Path, &str)) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_examples(&path, visit);
|
||||
continue;
|
||||
}
|
||||
if !is_example_source(&path) {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
visit(&path, &text);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_advanced_boundary_doc(text: &str) -> bool {
|
||||
text.contains("advanced/low-level north-star boundary sketch")
|
||||
}
|
||||
|
||||
fn is_example_source(path: &Path) -> bool {
|
||||
matches!(
|
||||
path.extension().and_then(|ext| ext.to_str()),
|
||||
Some("rs" | "heml" | "html" | "md")
|
||||
)
|
||||
}
|
||||
|
||||
fn allowed_example_script(path: &Path, line: &str) -> bool {
|
||||
// req: examples/005
|
||||
line.contains(r#"<script +src="self.runtime_src" defer></script>"#)
|
||||
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
|
||||
&& line.contains(r#"<script src="/island.js" defer></script>"#))
|
||||
|| (path.ends_with("examples/saas/templates/app_shell.heml")
|
||||
&& line.contains(r#"<script src="/metrics.js" defer></script>"#))
|
||||
|| (path.ends_with("examples/kanban/templates/legacy_sync_fixture.heml")
|
||||
&& line.contains(r#"<script type="module" src="/sync.js"></script>"#))
|
||||
}
|
||||
|
||||
fn contains_inline_event_handler(line: &str) -> bool {
|
||||
let bytes = line.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + 3 < bytes.len() {
|
||||
let boundary = bytes[i].is_ascii_whitespace() || bytes[i] == b'<';
|
||||
if boundary
|
||||
&& bytes[i + 1] == b'o'
|
||||
&& bytes[i + 2] == b'n'
|
||||
&& bytes[i + 3].is_ascii_lowercase()
|
||||
{
|
||||
let mut j = i + 4;
|
||||
while j < bytes.len() && bytes[j].is_ascii_lowercase() {
|
||||
j += 1;
|
||||
}
|
||||
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() && bytes[j] == b'=' {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
use hemx_core::{Effect, GeneratedTarget, ResourceId, ResourceKind, Slot};
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
fn block_on<F: Future>(future: F) -> F::Output {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let mut context = Context::from_waker(Waker::noop());
|
||||
loop {
|
||||
match future.as_mut().poll(&mut context) {
|
||||
Poll::Ready(output) => return output,
|
||||
Poll::Pending => std::thread::yield_now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CountTarget;
|
||||
|
||||
impl GeneratedTarget for CountTarget {
|
||||
fn __hemx_resource_id(self) -> ResourceId {
|
||||
ResourceId::new(ResourceKind::Slot, 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_handler(value: u32) -> Effect {
|
||||
Slot::<u32>::new(1).text(value)
|
||||
}
|
||||
|
||||
async fn async_handler(value: u32) -> Effect {
|
||||
std::future::ready(()).await;
|
||||
Slot::<u32>::new(1).text(value)
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct Rejected {
|
||||
value: u32,
|
||||
}
|
||||
|
||||
fn fallible_handler(value: u32) -> Result<Effect, Rejected> {
|
||||
if value == 0 {
|
||||
Err(Rejected { value })
|
||||
} else {
|
||||
Ok(sync_handler(value))
|
||||
}
|
||||
}
|
||||
|
||||
async fn fallible_async_handler(value: u32) -> Result<Effect, Rejected> {
|
||||
std::future::ready(()).await;
|
||||
if value == 0 {
|
||||
Err(Rejected { value })
|
||||
} else {
|
||||
Ok(sync_handler(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_sync_and_async_handlers_into_the_same_effect_inspector() {
|
||||
let sync = hemx_test::run(sync_handler, 41);
|
||||
assert!(sync.updates_text_containing(CountTarget, "41"));
|
||||
|
||||
let asynchronous = block_on(hemx_test::run_async(async_handler, 42));
|
||||
assert!(asynchronous.updates_text_containing(CountTarget, "42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_successful_fallible_handlers() {
|
||||
let sync = hemx_test::run_result(fallible_handler, 41).unwrap();
|
||||
assert!(sync.updates_text_containing(CountTarget, "41"));
|
||||
|
||||
let asynchronous = block_on(hemx_test::run_async_result(fallible_async_handler, 42)).unwrap();
|
||||
assert!(asynchronous.updates_text_containing(CountTarget, "42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_concrete_sync_and_async_handler_errors() {
|
||||
let sync = hemx_test::run_result(fallible_handler, 0).unwrap_err();
|
||||
assert_eq!(sync, Rejected { value: 0 });
|
||||
|
||||
let asynchronous =
|
||||
block_on(hemx_test::run_async_result(fallible_async_handler, 0)).unwrap_err();
|
||||
assert_eq!(asynchronous, Rejected { value: 0 });
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use hemx_core::{
|
||||
Effect, GeneratedTarget, InsertPosition, PatchMode, ResourceId, ResourceKind, ResourceRef,
|
||||
SafeHtml,
|
||||
};
|
||||
|
||||
fn panic_text<T>(result: std::thread::Result<T>) -> String {
|
||||
let panic = match result {
|
||||
Ok(_) => panic!("operation must panic"),
|
||||
Err(panic) => panic,
|
||||
};
|
||||
if let Some(message) = panic.downcast_ref::<String>() {
|
||||
message.clone()
|
||||
} else if let Some(message) = panic.downcast_ref::<&str>() {
|
||||
(*message).to_owned()
|
||||
} else {
|
||||
panic!("panic payload was not text")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_complete_documents_with_owned_structure() {
|
||||
let inspected = hemx_test::inspect_html_document(
|
||||
r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head><title>Todos & notes</title></head>
|
||||
<body><main><h1> Todos today </h1><p data-state="ready">two < three</p></main></body>
|
||||
</html>"#,
|
||||
);
|
||||
|
||||
inspected.assert_count("html", 1);
|
||||
inspected.assert_text("title", "Todos & notes");
|
||||
inspected.assert_text("main > h1", "Todos today");
|
||||
inspected.assert_attribute("p[data-state]", "data-state", "ready");
|
||||
|
||||
let paragraph = inspected.select("main p").unwrap();
|
||||
assert_eq!(paragraph.elements()[0].name(), "p");
|
||||
assert_eq!(paragraph.elements()[0].text(), "two < three");
|
||||
assert_eq!(
|
||||
paragraph.elements()[0].attribute("data-state"),
|
||||
Some("ready")
|
||||
);
|
||||
assert!(paragraph.elements()[0]
|
||||
.attributes()
|
||||
.any(|attribute| attribute == ("data-state", "ready")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_generated_targets_and_handles_without_raw_runtime_ids() {
|
||||
let target = TestTarget(ResourceKind::Slot, 42);
|
||||
let handle = hemx_core::Handle::<()>::new(7);
|
||||
let inspected = hemx_test::inspect_html_fragment(
|
||||
r#"<section data-sid="42"><button data-hid="7">Save</button></section>"#,
|
||||
);
|
||||
|
||||
inspected.assert_target(target);
|
||||
inspected.assert_handle(handle);
|
||||
assert_eq!(
|
||||
inspected.select_target(target).elements()[0].name(),
|
||||
"section"
|
||||
);
|
||||
assert_eq!(inspected.select_handle(handle).elements()[0].text(), "Save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_fragments_and_keeps_selections_owned() {
|
||||
let selection = {
|
||||
let inspected = hemx_test::inspect_html_fragment(
|
||||
r#"<ul><li class="todo">one</li><li class="todo">two</li></ul>"#,
|
||||
);
|
||||
inspected.assert_exists("ul > li.todo");
|
||||
inspected.assert_count("ul > li.todo", 2);
|
||||
inspected.select("li.todo").unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(selection.selector(), "li.todo");
|
||||
assert_eq!(selection.len(), 2);
|
||||
assert!(!selection.is_empty());
|
||||
assert_eq!(selection.elements()[0].text(), "one");
|
||||
assert_eq!(selection.elements()[1].text(), "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspects_generated_target_document_and_fragment_payloads() {
|
||||
let document_target = TestTarget(ResourceKind::Slot, 1);
|
||||
let document_effect = Effect::Patch {
|
||||
target: ResourceRef::unscoped(document_target.__hemx_resource_id()),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted(
|
||||
"<!doctype html><html><body><main id=app>ready</main></body></html>",
|
||||
),
|
||||
};
|
||||
let document = hemx_test::inspect(document_effect)
|
||||
.target_html_document(document_target)
|
||||
.unwrap();
|
||||
document.assert_text("main#app", "ready");
|
||||
assert!(document.origin().contains("Patch HTML effect"));
|
||||
|
||||
for (effect, operation) in [
|
||||
(
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)),
|
||||
position: InsertPosition::Last,
|
||||
html: SafeHtml::trusted("<li data-key=later>later</li>"),
|
||||
},
|
||||
"Insert",
|
||||
),
|
||||
(
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 2)),
|
||||
position: InsertPosition::First,
|
||||
html: SafeHtml::trusted("<li data-key=first>first</li>"),
|
||||
},
|
||||
"Insert",
|
||||
),
|
||||
] {
|
||||
let fragment = hemx_test::inspect(effect)
|
||||
.target_html_fragment(TestTarget(ResourceKind::Slot, 2))
|
||||
.unwrap();
|
||||
fragment.assert_count("li[data-key]", 1);
|
||||
assert!(fragment.origin().contains(operation));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_html_errors_explain_missing_non_html_and_ambiguous_effects() {
|
||||
let missing = hemx_test::inspect(Effect::Patch {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 9)),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("<p>other</p>"),
|
||||
})
|
||||
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(missing.contains("generated target"), "{missing}");
|
||||
assert!(missing.contains("found none"), "{missing}");
|
||||
assert!(missing.contains("all effects"), "{missing}");
|
||||
assert!(missing.contains("other"), "{missing}");
|
||||
|
||||
let non_html = hemx_test::inspect(Effect::Focus {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
|
||||
prevent_scroll: false,
|
||||
})
|
||||
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(non_html.contains("found none"), "{non_html}");
|
||||
assert!(non_html.contains("Focus"), "{non_html}");
|
||||
|
||||
let ambiguous = hemx_test::inspect(vec![
|
||||
Effect::Patch {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("<p>one</p>"),
|
||||
},
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 1)),
|
||||
position: InsertPosition::Last,
|
||||
html: SafeHtml::trusted("<p>two</p>"),
|
||||
},
|
||||
])
|
||||
.target_html_fragment(TestTarget(ResourceKind::Slot, 1))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(ambiguous.contains("found 2"), "{ambiguous}");
|
||||
assert!(ambiguous.contains("cannot choose"), "{ambiguous}");
|
||||
assert!(ambiguous.contains("one"), "{ambiguous}");
|
||||
assert!(ambiguous.contains("two"), "{ambiguous}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_and_assertion_failures_are_actionable() {
|
||||
let inspected = hemx_test::inspect_html_fragment(
|
||||
r#"<section><p class="actual">first</p><p class="actual">second</p></section>"#,
|
||||
);
|
||||
|
||||
let invalid = inspected.select("section[").unwrap_err().to_string();
|
||||
assert!(invalid.contains("invalid CSS selector"), "{invalid}");
|
||||
assert!(invalid.contains("section["), "{invalid}");
|
||||
assert!(invalid.contains("HTML fragment"), "{invalid}");
|
||||
|
||||
let missing = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|
||||
|| inspected.assert_exists("article.missing"),
|
||||
)));
|
||||
assert!(missing.contains("article.missing"), "{missing}");
|
||||
assert!(missing.contains("found none"), "{missing}");
|
||||
assert!(missing.contains("class=\"actual\""), "{missing}");
|
||||
|
||||
let duplicate = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|
||||
|| inspected.assert_text("p.actual", "first"),
|
||||
)));
|
||||
assert!(duplicate.contains("exactly one"), "{duplicate}");
|
||||
assert!(duplicate.contains("found 2"), "{duplicate}");
|
||||
assert!(duplicate.contains("first"), "{duplicate}");
|
||||
assert!(duplicate.contains("second"), "{duplicate}");
|
||||
|
||||
let wrong_attribute = panic_text(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
|
||||
|| inspected.assert_attribute("p:first-child", "class", "expected"),
|
||||
)));
|
||||
assert!(wrong_attribute.contains("data") || wrong_attribute.contains("class"));
|
||||
assert!(wrong_attribute.contains("expected"), "{wrong_attribute}");
|
||||
assert!(wrong_attribute.contains("actual"), "{wrong_attribute}");
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TestTarget(ResourceKind, u32);
|
||||
|
||||
impl GeneratedTarget for TestTarget {
|
||||
fn __hemx_resource_id(self) -> ResourceId {
|
||||
ResourceId::new(self.0, self.1)
|
||||
}
|
||||
}
|
||||
+57
-255
@@ -1,8 +1,18 @@
|
||||
use hemx_core::{
|
||||
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, KeyedSlot, NavigateMode,
|
||||
Payload, ResourceId, ResourceKind, ResourceRef, ScopeKey, Slot,
|
||||
Atom, BuildFingerprint, Effect, EffectBatch, Form, GeneratedTarget, HistoryMode,
|
||||
InsertPosition, KeyedSlot, PatchMode, ResourceId, ResourceKind, ResourceRef, SafeHtml,
|
||||
ScopeKey, Slot,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TestTarget(ResourceKind, u32);
|
||||
|
||||
impl GeneratedTarget for TestTarget {
|
||||
fn __hemx_resource_id(self) -> ResourceId {
|
||||
ResourceId::new(self.0, self.1)
|
||||
}
|
||||
}
|
||||
|
||||
fn panic_text<T>(result: std::thread::Result<T>) -> String {
|
||||
let panic = match result {
|
||||
Ok(_) => panic!("operation must panic"),
|
||||
@@ -26,24 +36,26 @@ fn inspects_tuple_effects() {
|
||||
|
||||
assert!(inspected.has_slot(count));
|
||||
assert!(inspected.has_atom(user));
|
||||
assert!(inspected.contains(&Effect::Put {
|
||||
assert!(inspected.contains(&Effect::Patch {
|
||||
target: ResourceRef::unscoped(count.id()),
|
||||
payload: Payload::text(42),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("42"),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_update_condition_is_bound_to_the_expected_target() {
|
||||
// req: test/018
|
||||
let expected_target = TestTarget(ResourceKind::Slot, 42);
|
||||
let inspected = hemx_test::inspect(vec![
|
||||
Effect::Put {
|
||||
Effect::Patch {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 42)),
|
||||
payload: Payload::Text(String::from("wrong payload")),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("wrong payload"),
|
||||
},
|
||||
Effect::Put {
|
||||
Effect::Patch {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
|
||||
payload: Payload::Text(String::from("expected fragment")),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("expected fragment"),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -68,11 +80,11 @@ fn text_update_condition_is_bound_to_the_expected_target() {
|
||||
|
||||
#[test]
|
||||
fn html_update_assertion_reports_expectation_and_actual_effects() {
|
||||
// req: test/018
|
||||
let target = TestTarget(ResourceKind::Slot, 42);
|
||||
let inspected = hemx_test::inspect(Effect::Put {
|
||||
let inspected = hemx_test::inspect(Effect::Patch {
|
||||
target: ResourceRef::unscoped(ResourceId::new(ResourceKind::Slot, 7)),
|
||||
payload: Payload::Html(String::from("<p>actual</p>")),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("<p>actual</p>"),
|
||||
});
|
||||
|
||||
let panic = std::panic::catch_unwind(|| {
|
||||
@@ -101,65 +113,64 @@ fn finds_keyed_slot_targets() {
|
||||
|
||||
#[test]
|
||||
fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
|
||||
// test req: test/008 req: test/009 req: test/018
|
||||
let expected = TestTarget(ResourceKind::Slot, 42);
|
||||
let other = TestTarget(ResourceKind::Slot, 7);
|
||||
let resource = ResourceId::new(ResourceKind::Slot, 42);
|
||||
let keyed_ref = ResourceRef::scoped(resource, ScopeKey::KeyValue("row-1".into()));
|
||||
let form = Form::<()>::new(11);
|
||||
let expected_emit = Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "card 42 saved".into(),
|
||||
let expected_emit = Effect::Dispatch {
|
||||
event: "saved".into(),
|
||||
payload: b"card 42 saved".to_vec(),
|
||||
};
|
||||
let inspected = hemx_test::inspect(vec![
|
||||
Effect::Put {
|
||||
Effect::Patch {
|
||||
target: ResourceRef::unscoped(other.__hemx_resource_id()),
|
||||
payload: Payload::Text("decoy needle".into()),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("decoy needle"),
|
||||
},
|
||||
Effect::Put {
|
||||
Effect::Patch {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
payload: Payload::Text("expected text".into()),
|
||||
mode: PatchMode::Morph,
|
||||
html: SafeHtml::trusted("expected text"),
|
||||
},
|
||||
Effect::Put {
|
||||
Effect::Patch {
|
||||
target: keyed_ref.clone(),
|
||||
payload: Payload::Html("<li data-key=\"row-1\">replacement</li>".into()),
|
||||
mode: PatchMode::Replace,
|
||||
html: SafeHtml::trusted("<li data-key=\"row-1\">replacement</li>"),
|
||||
},
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: "row-2".into(),
|
||||
payload: Payload::Html("<li>inserted</li>".into()),
|
||||
position: InsertPosition::After(ResourceRef::scoped(
|
||||
resource,
|
||||
ScopeKey::KeyValue("row-1".into()),
|
||||
)),
|
||||
html: SafeHtml::trusted("<li data-hemx-key=\"row-2\">inserted</li>"),
|
||||
},
|
||||
Effect::Prepend {
|
||||
Effect::Insert {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: "row-0".into(),
|
||||
payload: Payload::Html("<li>prepended</li>".into()),
|
||||
position: InsertPosition::First,
|
||||
html: SafeHtml::trusted("<li data-hemx-key=\"row-0\">prepended</li>"),
|
||||
},
|
||||
Effect::Remove {
|
||||
target: ResourceRef::unscoped(resource),
|
||||
key: Some("row-old".into()),
|
||||
target: ResourceRef::scoped(resource, ScopeKey::KeyValue("row-old".into())),
|
||||
},
|
||||
Effect::Focus {
|
||||
target: ResourceRef::unscoped(form.id()),
|
||||
prevent_scroll: false,
|
||||
},
|
||||
Effect::Navigate {
|
||||
Effect::Visit {
|
||||
url: "/cards/42".into(),
|
||||
mode: NavigateMode::Push,
|
||||
scroll: hemx_core::ScrollBehavior::Preserve,
|
||||
title: None,
|
||||
history: HistoryMode::Push,
|
||||
},
|
||||
expected_emit.clone(),
|
||||
Effect::Emit {
|
||||
name: "hemx:form-reset".into(),
|
||||
payload: form.id().id.to_string(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(!inspected.is_empty());
|
||||
assert_eq!(inspected.op_count(), 10);
|
||||
assert_eq!(inspected.op_count(), 9);
|
||||
assert!(inspected.contains(&expected_emit));
|
||||
assert!(!inspected.contains(&Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "wrong".into(),
|
||||
assert!(!inspected.contains(&Effect::Dispatch {
|
||||
event: "saved".into(),
|
||||
payload: b"wrong".to_vec(),
|
||||
}));
|
||||
assert!(inspected.has_resource(resource));
|
||||
assert!(inspected.has_resource(other.__hemx_resource_id()));
|
||||
@@ -195,11 +206,11 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "inserted"),
|
||||
Some("<li>inserted</li>")
|
||||
Some("<li data-hemx-key=\"row-2\">inserted</li>")
|
||||
);
|
||||
assert_eq!(
|
||||
inspected.target_html_containing(expected, "prepended"),
|
||||
Some("<li>prepended</li>")
|
||||
Some("<li data-hemx-key=\"row-0\">prepended</li>")
|
||||
);
|
||||
assert_eq!(inspected.target_html_containing(expected, "missing"), None);
|
||||
assert!(inspected.emits("saved", "card 42 saved"));
|
||||
@@ -219,120 +230,26 @@ fn inspector_predicates_bind_operation_target_scope_kind_and_payload() {
|
||||
assert!(!inspected.has_atom(Atom::<()>::new(42)));
|
||||
assert!(inspected.has_form(form));
|
||||
assert!(!inspected.has_form(Form::<()>::new(12)));
|
||||
assert!(inspected.resets_form(form));
|
||||
assert!(!inspected.resets_form(Form::<()>::new(12)));
|
||||
|
||||
let empty = hemx_test::inspect(Vec::<Effect>::new());
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.op_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_helpers_validate_parts_and_cover_unscoped_variants() {
|
||||
// test req: test/017
|
||||
assert_eq!(hemx_test::heading_selector("", 2), "h2");
|
||||
assert_eq!(hemx_test::list_item_selector(""), "li");
|
||||
assert_eq!(hemx_test::prose_selector(""), "p");
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 1)),
|
||||
"[data-sid=\"1\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Atom, 2)),
|
||||
"[data-aid=\"2\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Handle, 3)),
|
||||
"[data-hid=\"3\"]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Form, 4)),
|
||||
"[data-fid=\"4\"]"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
hemx_test::nav_link_selector("/path\\\"quoted"),
|
||||
"a[href=\"/path\\\\\\\"quoted\"]"
|
||||
);
|
||||
|
||||
hemx_test::assert_rendered_target(
|
||||
TestTarget(ResourceKind::Slot, 1),
|
||||
"slot",
|
||||
"<p data-sid=\"1\"></p>",
|
||||
);
|
||||
hemx_test::assert_rendered_handle(
|
||||
hemx_core::Handle::<()>::new(3),
|
||||
"handle",
|
||||
"<button data-hid=\"3\"></button>",
|
||||
);
|
||||
assert!(std::panic::catch_unwind(|| {
|
||||
hemx_test::assert_rendered_target(TestTarget(ResourceKind::Slot, 1), "slot", "<p></p>");
|
||||
})
|
||||
.is_err());
|
||||
assert!(std::panic::catch_unwind(|| {
|
||||
hemx_test::assert_rendered_handle(
|
||||
hemx_core::Handle::<()>::new(3),
|
||||
"handle",
|
||||
"<button data-other=\"3\"></button>",
|
||||
);
|
||||
})
|
||||
.is_err());
|
||||
|
||||
for invalid in ["", "two parts", ".class", "#id", "a>b", "a[b]"] {
|
||||
assert!(panic_text(std::panic::catch_unwind(|| {
|
||||
hemx_test::class_selector(invalid)
|
||||
}))
|
||||
.contains("class selector part"));
|
||||
}
|
||||
for (call, label) in [
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::element_class_selector("bad tag", "ok")),
|
||||
"element selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::element_class_selector("span", "bad class")),
|
||||
"class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("bad parent", "li", "row")),
|
||||
"parent class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "bad tag", "row")),
|
||||
"element selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_child_selector("list", "li", "bad class")),
|
||||
"class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("bad parent", "i")),
|
||||
"parent class selector part",
|
||||
),
|
||||
(
|
||||
std::panic::catch_unwind(|| hemx_test::class_descendant_selector("note", "bad tag")),
|
||||
"element selector part",
|
||||
),
|
||||
] {
|
||||
assert!(panic_text(call).contains(label));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_wire_reports_the_decode_failure_and_accepts_canonical_batches() {
|
||||
let batch = EffectBatch {
|
||||
abi_version: hemx_core::EFFECT_BATCH_ABI_VERSION,
|
||||
fingerprint: BuildFingerprint(9),
|
||||
ops: vec![Effect::Emit {
|
||||
name: "saved".into(),
|
||||
payload: "ok".into(),
|
||||
ops: vec![Effect::Dispatch {
|
||||
event: "saved".into(),
|
||||
payload: b"ok".to_vec(),
|
||||
}],
|
||||
};
|
||||
assert!(hemx_test::inspect_wire(&batch.to_wire()).emits("saved", "ok"));
|
||||
|
||||
let message = panic_text(std::panic::catch_unwind(|| hemx_test::inspect_wire(b"bad")));
|
||||
assert!(message.contains("invalid hemx effect wire response: Truncated"));
|
||||
// req: test/001 test req: wire/009 test
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -348,118 +265,3 @@ fn builds_generated_handle_form_bodies() {
|
||||
);
|
||||
assert_eq!(hemx_test::unknown_handle_form_body(99), "__h=99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_authoring_boundary_selectors() {
|
||||
assert_eq!(
|
||||
hemx_test::root_selector("techdemo"),
|
||||
r#"[data-hemx-root="techdemo"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::island_selector("orbit"),
|
||||
r#"[data-hemx-island="orbit"]"#
|
||||
);
|
||||
assert_eq!(hemx_test::island_attribute_name(), "data-hemx-island");
|
||||
assert_eq!(hemx_test::island_event_name("orbit"), "hemx:island-orbit");
|
||||
assert_eq!(
|
||||
hemx_test::sse_endpoint_marker("/events"),
|
||||
r#"data-hemx-sse="/events""#
|
||||
);
|
||||
assert_eq!(hemx_test::any_root_selector(), "[data-hemx-root]");
|
||||
assert_eq!(
|
||||
hemx_test::root_element_selector("main", "docs"),
|
||||
r#"main[data-hemx-root="docs"]"#
|
||||
);
|
||||
assert_eq!(hemx_test::document_body_selector(), "body");
|
||||
assert_eq!(hemx_test::document_title_selector(), "title");
|
||||
assert_eq!(
|
||||
hemx_test::runtime_script_selector(),
|
||||
r#"script[src^="/hemx."][src$=".js"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::target_selector(TestTarget(ResourceKind::Slot, 42)),
|
||||
r#"[data-sid="42"]"#
|
||||
);
|
||||
hemx_test::assert_rendered_target(
|
||||
TestTarget(ResourceKind::Slot, 42),
|
||||
"gallery::search_result",
|
||||
r#"<li data-sid="42">row</li>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::handle_button_selector(hemx_core::Handle::<()>::new(7)),
|
||||
r#"button[data-hid="7"]"#
|
||||
);
|
||||
hemx_test::assert_rendered_handle(
|
||||
hemx_core::Handle::<()>::new(7),
|
||||
"gallery::search",
|
||||
r#"<button data-hid="7">Search</button>"#,
|
||||
);
|
||||
assert_eq!(hemx_test::article_selector(), "article");
|
||||
assert_eq!(hemx_test::strong_text_selector(), "strong");
|
||||
assert_eq!(hemx_test::small_text_selector(), "small");
|
||||
assert_eq!(hemx_test::escaped_markup_selector("b"), "b");
|
||||
assert_eq!(hemx_test::heading_selector("article", 1), "article h1");
|
||||
assert_eq!(hemx_test::list_item_selector("ul"), "ul li");
|
||||
assert_eq!(hemx_test::prose_selector("article"), "article p");
|
||||
assert_eq!(hemx_test::form_selector("header"), "header form");
|
||||
assert_eq!(
|
||||
hemx_test::select_options_selector("column"),
|
||||
r#"select[name="column"] > option"#
|
||||
);
|
||||
assert_eq!(hemx_test::class_selector("lane"), ".lane");
|
||||
assert_eq!(
|
||||
hemx_test::element_class_selector("span", "presence"),
|
||||
"span.presence"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::class_child_selector("columns", "section", "column"),
|
||||
".columns > section.column"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::class_descendant_selector("impact", "i"),
|
||||
".impact i"
|
||||
);
|
||||
assert_eq!(hemx_test::disabled_button_selector(), "button[disabled]");
|
||||
assert_eq!(
|
||||
hemx_test::nav_link_selector("/architecture"),
|
||||
r#"a[href="/architecture"]"#
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::page_nav_link_selector("/docs"),
|
||||
r#"a[href="/docs"][data-hemx-nav]:not([data-hemx-handle])"#
|
||||
);
|
||||
assert_eq!(hemx_test::island_snapshot_marker(), "data-island-snapshot=");
|
||||
assert_eq!(
|
||||
hemx_test::island_readout_selector(),
|
||||
"[data-island-readout]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::scoped_island_readout_selector("#probe-island"),
|
||||
"#probe-island [data-island-readout]"
|
||||
);
|
||||
assert_eq!(
|
||||
hemx_test::keyed_selector(".work-card", 4),
|
||||
r#".work-card[data-key="4"]"#
|
||||
);
|
||||
assert_eq!(hemx_test::keyed_items_selector("li"), "li[data-key]");
|
||||
|
||||
let probe = hemx_test::island_probe_script(
|
||||
"probe-\"island",
|
||||
"orbit\\bridge",
|
||||
"line1\nline2\rline3\tend",
|
||||
"detail \"quoted\"",
|
||||
);
|
||||
assert!(probe.contains(r#"island.id = "probe-\"island";"#));
|
||||
assert!(probe.contains(r#"const islandName = "orbit\\bridge";"#));
|
||||
assert!(probe.contains(r#""line1\nline2\rline3\tend""#));
|
||||
assert!(probe.contains(r#"detail: "detail \"quoted\"""#));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct TestTarget(ResourceKind, u32);
|
||||
|
||||
impl GeneratedTarget for TestTarget {
|
||||
fn __hemx_resource_id(self) -> ResourceId {
|
||||
ResourceId::new(self.0, self.1)
|
||||
}
|
||||
}
|
||||
|
||||
+228
-79
@@ -1,116 +1,265 @@
|
||||
use hemx_test::TestProcess;
|
||||
use hemx_test::{ProcessError, TestProcess};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn process_harness_reports_early_exit_with_context() {
|
||||
// req: test/019
|
||||
fn current_test_command(helper: &str) -> Command {
|
||||
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
|
||||
command.arg("--exact").arg(helper).arg("--nocapture");
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("helper_process_exits_successfully")
|
||||
.arg("--nocapture");
|
||||
|
||||
let error = match TestProcess::start(
|
||||
command,
|
||||
"short-lived helper",
|
||||
"127.0.0.1:9",
|
||||
Duration::from_secs(2),
|
||||
) {
|
||||
Ok(_) => panic!("a process that exits before readiness must fail startup"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(message.contains("short-lived helper"), "{message}");
|
||||
assert!(message.contains("127.0.0.1:9"), "{message}");
|
||||
assert!(message.contains("exited with"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_harness_waits_for_readiness_and_reaps_on_drop() {
|
||||
// test req: test/019
|
||||
fn unused_loopback_addr() -> String {
|
||||
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = reservation.local_addr().unwrap().to_string();
|
||||
let address = reservation.local_addr().unwrap().to_string();
|
||||
drop(reservation);
|
||||
address
|
||||
}
|
||||
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("helper_process_listens")
|
||||
.arg("--nocapture")
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &addr);
|
||||
|
||||
let process = TestProcess::start(command, "listening helper", &addr, Duration::from_secs(2))
|
||||
.expect("readiness must observe the helper listener");
|
||||
assert!(TcpStream::connect(&addr).is_ok());
|
||||
drop(process);
|
||||
|
||||
fn wait_until_closed(address: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while TcpStream::connect(&addr).is_ok() && Instant::now() < deadline {
|
||||
while TcpStream::connect(address).is_ok() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert!(
|
||||
TcpStream::connect(&addr).is_err(),
|
||||
"drop must reap the helper"
|
||||
TcpStream::connect(address).is_err(),
|
||||
"child listener {address} must be closed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_harness_reports_spawn_and_readiness_timeout_errors() {
|
||||
// test req: test/019
|
||||
let spawn_error = match TestProcess::start(
|
||||
Command::new("/definitely/not/a/hemx/executable"),
|
||||
"missing helper",
|
||||
"127.0.0.1:9",
|
||||
Duration::from_millis(10),
|
||||
) {
|
||||
Ok(_) => panic!("spawn failure must be returned, not panic"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(spawn_error
|
||||
.to_string()
|
||||
.contains("failed to spawn missing helper"));
|
||||
fn builder_waits_for_delayed_tcp_readiness_and_captures_output() {
|
||||
let address = unused_loopback_addr();
|
||||
let process = TestProcess::builder(current_test_command("helper_process_listens"))
|
||||
.label("delayed TCP helper")
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &address)
|
||||
.env("HEMX_TEST_PROCESS_DELAY_MS", "75")
|
||||
.tcp(&address)
|
||||
.timeout(Duration::from_secs(2))
|
||||
.poll_interval(Duration::from_millis(10))
|
||||
.start()
|
||||
.expect("readiness must observe the helper listener");
|
||||
|
||||
let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let unused_addr = reservation.local_addr().unwrap().to_string();
|
||||
drop(reservation);
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.arg("--exact")
|
||||
.arg("helper_process_sleeps")
|
||||
.arg("--nocapture")
|
||||
.env("HEMX_TEST_PROCESS_SLEEP", "1");
|
||||
let timeout = match TestProcess::start(
|
||||
command,
|
||||
"non-listening helper",
|
||||
&unused_addr,
|
||||
Duration::from_millis(100),
|
||||
) {
|
||||
Ok(_) => panic!("non-listening process must time out"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let message = timeout.to_string();
|
||||
assert!(message.contains("timed out"), "{message}");
|
||||
assert!(message.contains("non-listening helper"), "{message}");
|
||||
assert!(process.id().is_some());
|
||||
assert!(TcpStream::connect(&address).is_ok());
|
||||
assert!(process.stdout().contains("tcp helper ready"));
|
||||
drop(process);
|
||||
wait_until_closed(&address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_exits_successfully() {}
|
||||
fn builder_waits_for_successful_http_readiness() {
|
||||
let address = unused_loopback_addr();
|
||||
let process = TestProcess::builder(current_test_command("helper_process_serves_http"))
|
||||
.label("HTTP helper")
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &address)
|
||||
.http(&address, "/health")
|
||||
.timeout(Duration::from_secs(2))
|
||||
.poll_interval(Duration::from_millis(10))
|
||||
.start()
|
||||
.expect("the second health response is successful");
|
||||
|
||||
assert!(process.stdout().contains("http helper ready"));
|
||||
drop(process);
|
||||
wait_until_closed(&address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_exit_reports_bounded_stdout_and_stderr() {
|
||||
let error = TestProcess::builder(current_test_command("helper_process_is_noisy"))
|
||||
.label("noisy helper")
|
||||
.env("HEMX_TEST_PROCESS_NOISY", "1")
|
||||
.tcp("127.0.0.1:9")
|
||||
.output_limit(256)
|
||||
.timeout(Duration::from_secs(2))
|
||||
.start()
|
||||
.unwrap_err();
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(matches!(error, ProcessError::EarlyExit { .. }));
|
||||
assert!(message.contains("noisy helper"), "{message}");
|
||||
assert!(message.contains("exited with"), "{message}");
|
||||
assert!(message.contains("earlier bytes omitted"), "{message}");
|
||||
assert!(message.contains("stdout marker"), "{message}");
|
||||
assert!(message.contains("stderr marker"), "{message}");
|
||||
assert!(
|
||||
message.len() < 1_500,
|
||||
"diagnostic was not bounded: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_reports_readiness_attempts_output_and_cleanup() {
|
||||
let address = unused_loopback_addr();
|
||||
let error = TestProcess::builder(current_test_command("helper_process_stays_unready"))
|
||||
.label("unready HTTP helper")
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &address)
|
||||
.http(&address, "/health")
|
||||
.timeout(Duration::from_millis(100))
|
||||
.poll_interval(Duration::from_millis(10))
|
||||
.start()
|
||||
.unwrap_err();
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(matches!(error, ProcessError::TimedOut { .. }));
|
||||
assert!(message.contains("timed out"), "{message}");
|
||||
assert!(message.contains("unready HTTP helper"), "{message}");
|
||||
assert!(message.contains("readiness attempts"), "{message}");
|
||||
assert!(message.contains("unready helper started"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_spawn_and_occupied_http_fail_honestly() {
|
||||
let missing_readiness = TestProcess::builder(current_test_command("helper_process_sleeps"))
|
||||
.label("unconfigured helper")
|
||||
.start()
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
missing_readiness,
|
||||
ProcessError::Configuration { .. }
|
||||
));
|
||||
|
||||
let invalid_http = TestProcess::builder(current_test_command("helper_process_sleeps"))
|
||||
.label("invalid HTTP helper")
|
||||
.http("127.0.0.1:9", "health")
|
||||
.start()
|
||||
.unwrap_err();
|
||||
assert!(invalid_http
|
||||
.to_string()
|
||||
.contains("path must start with '/'"));
|
||||
|
||||
let spawn = TestProcess::builder(Command::new("/definitely/not/a/hemx/executable"))
|
||||
.label("missing helper")
|
||||
.tcp("127.0.0.1:9")
|
||||
.start()
|
||||
.unwrap_err();
|
||||
assert!(matches!(spawn, ProcessError::Spawn { .. }));
|
||||
assert!(spawn.to_string().contains("failed to spawn missing helper"));
|
||||
|
||||
let occupied = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = occupied.local_addr().unwrap().to_string();
|
||||
let occupied_error = TestProcess::builder(current_test_command("helper_process_sleeps"))
|
||||
.label("HTTP ownership helper")
|
||||
.env("HEMX_TEST_PROCESS_SLEEP", "1")
|
||||
.http(&address, "/health")
|
||||
.timeout(Duration::from_millis(100))
|
||||
.poll_interval(Duration::from_millis(10))
|
||||
.start()
|
||||
.unwrap_err();
|
||||
assert!(matches!(occupied_error, ProcessError::TimedOut { .. }));
|
||||
drop(occupied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_shutdown_and_drop_after_panic_reap_the_child() {
|
||||
let address = unused_loopback_addr();
|
||||
let mut process = TestProcess::builder(current_test_command("helper_process_listens"))
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &address)
|
||||
.tcp(&address)
|
||||
.start()
|
||||
.unwrap();
|
||||
process.shutdown().unwrap();
|
||||
process.shutdown().unwrap();
|
||||
assert!(process.id().is_none());
|
||||
assert!(process.exit_status().is_some());
|
||||
wait_until_closed(&address);
|
||||
|
||||
let panic_address = unused_loopback_addr();
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let _process = TestProcess::builder(current_test_command("helper_process_listens"))
|
||||
.env("HEMX_TEST_PROCESS_ADDR", &panic_address)
|
||||
.tcp(&panic_address)
|
||||
.start()
|
||||
.unwrap();
|
||||
panic!("exercise panic cleanup");
|
||||
});
|
||||
assert!(result.is_err());
|
||||
wait_until_closed(&panic_address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_start_still_waits_for_tcp_and_reaps() {
|
||||
let address = unused_loopback_addr();
|
||||
let mut command = current_test_command("helper_process_listens");
|
||||
command.env("HEMX_TEST_PROCESS_ADDR", &address);
|
||||
let process = TestProcess::start(
|
||||
command,
|
||||
"compatibility helper",
|
||||
&address,
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
.unwrap();
|
||||
drop(process);
|
||||
wait_until_closed(&address);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_listens() {
|
||||
let Ok(addr) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
|
||||
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
|
||||
return;
|
||||
};
|
||||
let _listener = TcpListener::bind(addr).expect("bind helper listener");
|
||||
if let Ok(delay) = std::env::var("HEMX_TEST_PROCESS_DELAY_MS") {
|
||||
std::thread::sleep(Duration::from_millis(delay.parse().unwrap()));
|
||||
}
|
||||
let _listener = TcpListener::bind(address).expect("bind helper listener");
|
||||
println!("tcp helper ready");
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_serves_http() {
|
||||
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
|
||||
return;
|
||||
};
|
||||
let listener = TcpListener::bind(address).expect("bind HTTP helper listener");
|
||||
println!("http helper ready");
|
||||
for status in ["503 Service Unavailable", "204 No Content"] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0_u8; 512];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /health HTTP/1.1"));
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_stays_unready() {
|
||||
let Ok(address) = std::env::var("HEMX_TEST_PROCESS_ADDR") else {
|
||||
return;
|
||||
};
|
||||
let listener = TcpListener::bind(address).expect("bind unready helper listener");
|
||||
println!("unready helper started");
|
||||
loop {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0_u8; 512];
|
||||
let _ = stream.read(&mut request);
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_is_noisy() {
|
||||
if std::env::var_os("HEMX_TEST_PROCESS_NOISY").is_none() {
|
||||
return;
|
||||
}
|
||||
println!("{}\nstdout marker", "o".repeat(4_096));
|
||||
eprintln!("{}\nstderr marker", "e".repeat(4_096));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_process_sleeps() {
|
||||
if std::env::var_os("HEMX_TEST_PROCESS_SLEEP").is_some() {
|
||||
println!("sleeping helper started");
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user