#![cfg(feature = "system-tests")]
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chromiumoxide::cdp::browser_protocol::log::{EventEntryAdded, LogEntryLevel};
use chromiumoxide::cdp::js_protocol::runtime::{
ConsoleApiCalledType, EventConsoleApiCalled, EventExceptionThrown,
};
use chromiumoxide::{Browser, BrowserConfig};
use futures::StreamExt as _;
use crate::config::AutumnConfig;
use crate::route::Route;
const DEFAULT_BROWSER_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_HX_SETTLE_TIMEOUT: Duration = Duration::from_secs(2);
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const ASSERTION_TIMEOUT: Duration = Duration::from_secs(5);
const CONSOLE_ERROR_GRACE: Duration = Duration::from_millis(500);
pub use crate::browser_detect::BrowserCheck;
use crate::browser_detect::{browser_candidates, find_chromium};
#[derive(Debug, thiserror::Error)]
pub enum SystemTestError {
#[error(
"Chromium browser not found. Searched:\n{}\n\n\
To install: apt-get install chromium-browser\n\
Or set AUTUMN_CHROMIUM=/path/to/chrome",
searched.iter().map(|p| format!(" {}", p.display())).collect::<Vec<_>>().join("\n")
)]
BrowserNotFound {
searched: Vec<PathBuf>,
},
#[error("{message}")]
AssertionFailed {
message: String,
artifact_path: Option<String>,
},
#[error("assertion timed out after {timeout:?}: {message}")]
Timeout {
message: String,
timeout: Duration,
},
#[error("artifact write error: {0}")]
ArtifactIo(#[from] std::io::Error),
#[error("browser error: {0}")]
Browser(#[from] chromiumoxide::error::CdpError),
}
const TRANSIENT_NAVIGATION_MARKERS: &[&str] = &[
"cannot find context with specified id",
"execution context was destroyed",
"inspected target navigated or closed",
];
fn is_transient_navigation_error(err: &chromiumoxide::error::CdpError) -> bool {
let message = match err {
chromiumoxide::error::CdpError::Chrome(inner) => inner.message.as_str(),
chromiumoxide::error::CdpError::ChromeMessage(message) => message.as_str(),
_ => return false,
};
let message = message.to_ascii_lowercase();
TRANSIENT_NAVIGATION_MARKERS
.iter()
.any(|marker| message.contains(marker))
}
async fn poll_until_deadline<F, Fut>(
deadline: tokio::time::Instant,
transient_means: bool,
mut probe: F,
) -> Result<bool, SystemTestError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<bool, chromiumoxide::error::CdpError>>,
{
loop {
match probe().await {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(e) if is_transient_navigation_error(&e) => {
if transient_means {
return Ok(true);
}
}
Err(e) => return Err(e.into()),
}
if tokio::time::Instant::now() >= deadline {
return Ok(false);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
#[must_use]
pub fn artifact_dir(test_name: &str) -> PathBuf {
let base =
std::env::var("CARGO_TARGET_DIR").map_or_else(|_| PathBuf::from("target"), PathBuf::from);
base.join("system-tests").join(test_name)
}
#[must_use]
pub struct SystemTest {
routes: Vec<Route>,
config: AutumnConfig,
artifact_dir_override: Option<PathBuf>,
browser_timeout: Duration,
hx_settle_timeout: Duration,
state_override: Option<crate::state::AppState>,
custom_layers: Vec<crate::app::CustomLayerRegistration>,
}
impl Default for SystemTest {
fn default() -> Self {
Self::new()
}
}
impl SystemTest {
pub fn new() -> Self {
let mut security = crate::security::SecurityConfig::default();
security.csrf.enabled = false;
let config = AutumnConfig {
profile: Some("test".into()),
security,
..Default::default()
};
Self {
routes: Vec::new(),
config,
artifact_dir_override: None,
browser_timeout: DEFAULT_BROWSER_TIMEOUT,
hx_settle_timeout: DEFAULT_HX_SETTLE_TIMEOUT,
state_override: None,
custom_layers: Vec::new(),
}
}
pub fn routes(mut self, routes: impl Into<Vec<Route>>) -> Self {
self.routes.extend(routes.into());
self
}
pub fn state(mut self, state: crate::state::AppState) -> Self {
self.state_override = Some(state);
self
}
pub fn layer<L: crate::app::IntoAppLayer>(mut self, layer: L) -> Self {
self.custom_layers
.push(crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<L>(),
type_name: std::any::type_name::<L>(),
layer: layer.erase(),
});
self
}
pub fn artifact_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.artifact_dir_override = Some(dir.into());
self
}
pub const fn browser_timeout(mut self, t: Duration) -> Self {
self.browser_timeout = t;
self
}
pub const fn hx_settle_timeout(mut self, t: Duration) -> Self {
self.hx_settle_timeout = t;
self
}
pub async fn build(self) -> Result<SystemTestRunner, SystemTestError> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(SystemTestError::ArtifactIo)?;
let addr = listener.local_addr().map_err(SystemTestError::ArtifactIo)?;
let base_url = format!("http://127.0.0.1:{}", addr.port());
let browser_timeout = self.browser_timeout;
let hx_settle_timeout = self.hx_settle_timeout;
let artifact_dir = self
.artifact_dir_override
.clone()
.unwrap_or_else(default_artifact_dir);
let router = self.into_router();
let service = tower::Layer::layer(&crate::middleware::MethodOverrideLayer::new(), router);
let make_service = axum::ServiceExt::<axum::extract::Request>::into_make_service(service);
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let server_handle = tokio::spawn(async move {
let _ = axum::serve(listener, make_service)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
})
.await;
});
let (browser, user_data_dir) = launch_browser(browser_timeout).await?;
Ok(SystemTestRunner {
base_url,
browser: Some(browser),
artifact_dir,
user_data_dir,
hx_settle_timeout,
_shutdown: Some(shutdown_tx),
_server_handle: Some(server_handle),
})
}
fn into_router(self) -> axum::Router {
let (config, state) = if let Some(state) = self.state_override {
let config = state
.extension::<AutumnConfig>()
.map(|arc| (*arc).clone())
.unwrap_or_default();
(config, state)
} else {
let state = crate::state::AppState::for_test().with_profile("test");
state.insert_extension(self.config.clone());
(self.config, state)
};
crate::router::try_build_router_with_layers(self.routes, &config, state, self.custom_layers)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
pub async fn attach(base_url: impl Into<String>) -> Result<SystemTestRunner, SystemTestError> {
Self::attach_with_timeout(base_url, DEFAULT_BROWSER_TIMEOUT).await
}
pub async fn attach_with_timeout(
base_url: impl Into<String>,
browser_timeout: Duration,
) -> Result<SystemTestRunner, SystemTestError> {
let (browser, user_data_dir) = launch_browser(browser_timeout).await?;
Ok(SystemTestRunner {
base_url: base_url.into(),
browser: Some(browser),
artifact_dir: default_artifact_dir(),
user_data_dir,
hx_settle_timeout: DEFAULT_HX_SETTLE_TIMEOUT,
_shutdown: None,
_server_handle: None,
})
}
}
async fn launch_browser(browser_timeout: Duration) -> Result<(Browser, PathBuf), SystemTestError> {
let browser_path = find_chromium().ok_or_else(|| {
let searched = browser_candidates();
SystemTestError::BrowserNotFound { searched }
})?;
let user_data_dir = unique_user_data_dir();
let config = BrowserConfig::builder()
.chrome_executable(browser_path)
.user_data_dir(&user_data_dir)
.no_sandbox()
.arg("disable-dev-shm-usage")
.arg("disable-gpu")
.launch_timeout(browser_timeout)
.build()
.map_err(|msg| SystemTestError::Browser(chromiumoxide::error::CdpError::msg(msg)))?;
let (browser, handler) = tokio::time::timeout(browser_timeout, Browser::launch(config))
.await
.map_err(|_| SystemTestError::Timeout {
message: "timed out waiting for Chromium to launch".into(),
timeout: browser_timeout,
})??;
tokio::spawn(async move {
handler.for_each(|_| async {}).await;
});
Ok((browser, user_data_dir))
}
fn unique_user_data_dir() -> PathBuf {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::env::temp_dir().join(format!("chromiumoxide-runner-{}-{n}", std::process::id()))
}
fn default_artifact_dir() -> PathBuf {
let name = std::thread::current()
.name()
.unwrap_or("system_test")
.replace("::", "__");
artifact_dir(&name)
}
pub struct SystemTestRunner {
base_url: String,
browser: Option<Browser>,
artifact_dir: PathBuf,
user_data_dir: PathBuf,
hx_settle_timeout: Duration,
_shutdown: Option<tokio::sync::oneshot::Sender<()>>,
_server_handle: Option<tokio::task::JoinHandle<()>>,
}
impl Drop for SystemTestRunner {
fn drop(&mut self) {
drop(self.browser.take());
let user_data_dir = self.user_data_dir.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
let _ = std::fs::remove_dir_all(user_data_dir);
});
}
}
impl SystemTestRunner {
pub async fn page(&self) -> Result<Page, SystemTestError> {
let browser = self
.browser
.as_ref()
.expect("SystemTestRunner::browser is only None during Drop");
let cdp_page = browser.new_page("about:blank").await?;
cdp_page.enable_runtime().await?;
cdp_page.enable_log().await?;
let console_errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
spawn_console_error_capture(&cdp_page, Arc::clone(&console_errors)).await?;
Ok(Page {
inner: cdp_page,
base_url: self.base_url.clone(),
artifact_dir: self.artifact_dir.clone(),
hx_settle_timeout: self.hx_settle_timeout,
console_errors,
})
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
}
async fn spawn_console_error_capture(
page: &chromiumoxide::page::Page,
sink: Arc<Mutex<Vec<String>>>,
) -> Result<(), SystemTestError> {
let mut console_events = page.event_listener::<EventConsoleApiCalled>().await?;
let mut exception_events = page.event_listener::<EventExceptionThrown>().await?;
let mut log_events = page.event_listener::<EventEntryAdded>().await?;
tokio::spawn(async move {
let mut console_open = true;
let mut exception_open = true;
let mut log_open = true;
while console_open || exception_open || log_open {
tokio::select! {
event = console_events.next(), if console_open => {
match event {
Some(event) => {
if event.r#type == ConsoleApiCalledType::Error {
let text = event
.args
.iter()
.map(remote_object_to_string)
.collect::<Vec<_>>()
.join(" ");
sink.lock().unwrap().push(text);
}
}
None => console_open = false,
}
}
event = exception_events.next(), if exception_open => {
match event {
Some(event) => {
sink.lock().unwrap().push(event.exception_details.text.clone());
}
None => exception_open = false,
}
}
event = log_events.next(), if log_open => {
match event {
Some(event) => {
if event.entry.level == LogEntryLevel::Error {
sink.lock().unwrap().push(event.entry.text.clone());
}
}
None => log_open = false,
}
}
}
}
});
Ok(())
}
fn remote_object_to_string(obj: &chromiumoxide::cdp::js_protocol::runtime::RemoteObject) -> String {
if let Some(value) = &obj.value {
if let Some(s) = value.as_str() {
return s.to_string();
}
return value.to_string();
}
obj.description
.clone()
.or_else(|| obj.class_name.clone())
.unwrap_or_default()
}
pub struct Page {
inner: chromiumoxide::page::Page,
base_url: String,
artifact_dir: PathBuf,
hx_settle_timeout: Duration,
console_errors: Arc<Mutex<Vec<String>>>,
}
impl Page {
pub async fn visit(&self, path: &str) -> Result<&Self, SystemTestError> {
let url = format!("{}{path}", self.base_url);
self.inner.goto(url).await?;
Ok(self)
}
pub async fn fill(&self, selector: &str, value: &str) -> Result<&Self, SystemTestError> {
let element = self.inner.find_element(selector).await?;
element.click().await?;
self.inner
.evaluate(format!(
"(function() {{ var el = document.querySelector({}); \
if (el) {{ el.value = ''; }} }})()",
js_string_literal(selector)
))
.await?;
element.type_str(value).await?;
if value.is_empty() {
self.inner
.evaluate(format!(
"(function() {{ var el = document.querySelector({}); \
if (el) {{ el.dispatchEvent(new Event('input', {{ bubbles: true }})); }} }})()",
js_string_literal(selector)
))
.await?;
}
self.inner
.evaluate(format!(
"(function() {{ var el = document.querySelector({}); \
if (el) {{ el.dispatchEvent(new Event('change', {{ bubbles: true }})); }} }})()",
js_string_literal(selector)
))
.await?;
self.wait_for_hx_settle().await?;
Ok(self)
}
pub async fn click(&self, selector_or_label: &str) -> Result<&Self, SystemTestError> {
if let Ok(element) = self.inner.find_element(selector_or_label).await {
element.click().await?;
} else {
let js = format!(
"(function() {{ \
var want = {}; \
var normWant = want.replace(/\\s+/g, ' ').trim(); \
var nodes = Array.from(document.querySelectorAll( \
'button,a,input[value],label,[role=button],[role=link]')); \
for (var i = 0; i < nodes.length; i++) {{ \
var el = nodes[i]; \
if (el.disabled) {{ continue; }} \
if (el.getClientRects().length === 0) {{ continue; }} \
var cs = window.getComputedStyle(el); \
if (cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) {{ continue; }} \
var text = el.tagName === 'INPUT' \
? (el.value || '') \
: (el.textContent || ''); \
if (text.replace(/\\s+/g, ' ').trim() === normWant) {{ \
el.click(); return true; \
}} \
}} \
return false; \
}})()",
js_string_literal(selector_or_label)
);
let clicked: bool = self.inner.evaluate(js).await?.into_value().unwrap_or(false);
if !clicked {
return Err(SystemTestError::AssertionFailed {
message: format!("element not found by selector or text: {selector_or_label}"),
artifact_path: None,
});
}
}
self.wait_for_hx_settle().await?;
Ok(self)
}
pub async fn expect_text(&self, text: &str) -> Result<&Self, SystemTestError> {
let deadline = tokio::time::Instant::now() + ASSERTION_TIMEOUT;
let js = format!(
"document.body && document.body.innerText.includes({})",
js_string_literal(text)
);
if poll_until_deadline(deadline, false, || self.evaluate_bool(js.clone(), false)).await? {
return Ok(self);
}
let artifact = self.write_failure_artifacts("expect_text").await.ok();
Err(SystemTestError::AssertionFailed {
message: format!("expected text {text:?} in page body"),
artifact_path: artifact,
})
}
pub async fn expect_url(&self, pattern: &str) -> Result<&Self, SystemTestError> {
let deadline = tokio::time::Instant::now() + ASSERTION_TIMEOUT;
let js = format!(
"window.location.href.includes({})",
js_string_literal(pattern)
);
if poll_until_deadline(deadline, false, || self.evaluate_bool(js.clone(), false)).await? {
return Ok(self);
}
let current_url: String = self
.inner
.evaluate("window.location.href")
.await
.ok()
.and_then(|v| v.into_value::<String>().ok())
.unwrap_or_else(|| "<unknown>".into());
let artifact = self.write_failure_artifacts("expect_url").await.ok();
Err(SystemTestError::AssertionFailed {
message: format!("expected URL to contain {pattern:?}, got {current_url:?}"),
artifact_path: artifact,
})
}
pub async fn expect_attribute(
&self,
selector: &str,
attr: &str,
value: &str,
) -> Result<&Self, SystemTestError> {
let deadline = tokio::time::Instant::now() + ASSERTION_TIMEOUT;
let js = format!(
"(function() {{ \
var el = document.querySelector({sel}); \
return el && el.getAttribute({attr}) === {val}; \
}})()",
sel = js_string_literal(selector),
attr = js_string_literal(attr),
val = js_string_literal(value),
);
if poll_until_deadline(deadline, false, || self.evaluate_bool(js.clone(), false)).await? {
return Ok(self);
}
let artifact = self.write_failure_artifacts("expect_attribute").await.ok();
Err(SystemTestError::AssertionFailed {
message: format!("expected [{attr}={value:?}] on {selector:?}"),
artifact_path: artifact,
})
}
pub async fn expect_hx_settle(&self) -> Result<&Self, SystemTestError> {
self.wait_for_hx_settle().await?;
Ok(self)
}
#[must_use]
pub fn console_errors(&self) -> Vec<String> {
self.console_errors.lock().unwrap().clone()
}
pub async fn expect_no_console_errors(&self) -> Result<&Self, SystemTestError> {
let deadline = tokio::time::Instant::now() + CONSOLE_ERROR_GRACE;
loop {
let errors = self.console_errors();
if !errors.is_empty() {
let artifact = self
.write_failure_artifacts("expect_no_console_errors")
.await
.ok();
return Err(SystemTestError::AssertionFailed {
message: format!(
"page produced {} console error(s): {errors:?}",
errors.len()
),
artifact_path: artifact,
});
}
if tokio::time::Instant::now() >= deadline {
return Ok(self);
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn expect_sse_event(
&self,
stream_id: &str,
predicate: impl Fn(&str) -> bool,
) -> Result<&Self, SystemTestError> {
let timeout = Duration::from_secs(10);
let deadline = tokio::time::Instant::now() + timeout;
loop {
let js = format!(
"(function() {{ \
var raw = {id}; \
var el = document.getElementById(raw); \
if (!el) {{ \
var sel = raw.startsWith('#') ? raw : raw; \
try {{ el = document.querySelector(raw); }} catch(e) {{}} \
}} \
return el ? el.innerText : null; \
}})()",
id = js_string_literal(stream_id)
);
let text: Option<String> = match self.inner.evaluate(js).await {
Ok(result) => result.into_value().ok(),
Err(e) if is_transient_navigation_error(&e) => None,
Err(e) => return Err(e.into()),
};
if let Some(ref t) = text
&& predicate(t)
{
return Ok(self);
}
if tokio::time::Instant::now() >= deadline {
let artifact = self.write_failure_artifacts("expect_sse_event").await.ok();
return Err(SystemTestError::AssertionFailed {
message: format!(
"SSE event: element {stream_id:?} content {:?} did not satisfy predicate",
text.as_deref().unwrap_or("<not found>")
),
artifact_path: artifact,
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn snapshot(&self) -> Result<PathBuf, SystemTestError> {
self.write_screenshot("snapshot").await
}
pub async fn evaluate(
&self,
js: impl Into<String>,
) -> Result<chromiumoxide::js::EvaluationResult, SystemTestError> {
let js_str: String = js.into();
let res = self.inner.evaluate(js_str).await?;
Ok(res)
}
async fn wait_for_hx_settle(&self) -> Result<(), SystemTestError> {
let deadline = tokio::time::Instant::now() + self.hx_settle_timeout;
let settled = poll_until_deadline(deadline, true, || {
self.evaluate_bool(
"document.querySelectorAll('.htmx-request,.htmx-settling,.htmx-swapping').length === 0"
.to_owned(),
true,
)
})
.await?;
if settled {
return Ok(());
}
Err(SystemTestError::Timeout {
message: "htmx did not settle".into(),
timeout: self.hx_settle_timeout,
})
}
async fn evaluate_bool(
&self,
js: String,
default: bool,
) -> Result<bool, chromiumoxide::error::CdpError> {
Ok(self
.inner
.evaluate(js)
.await?
.into_value()
.unwrap_or(default))
}
async fn write_failure_artifacts(&self, label: &str) -> Result<String, SystemTestError> {
let dir = &self.artifact_dir;
tokio::fs::create_dir_all(dir).await?;
let base = dir.join(label);
let base_str = base.to_string_lossy().into_owned();
let png_path = base.with_extension("png");
if let Ok(bytes) = self
.inner
.screenshot(chromiumoxide::page::ScreenshotParams::builder().build())
.await
{
let _ = tokio::fs::write(&png_path, bytes).await;
}
let html_path = base.with_extension("html");
if let Ok(html) = self.inner.content().await {
let _ = tokio::fs::write(&html_path, html).await;
}
Ok(base_str)
}
async fn write_screenshot(&self, label: &str) -> Result<PathBuf, SystemTestError> {
let dir = &self.artifact_dir;
tokio::fs::create_dir_all(dir).await?;
let png_path = dir.join(label).with_extension("png");
let bytes = self
.inner
.screenshot(chromiumoxide::page::ScreenshotParams::builder().build())
.await?;
tokio::fs::write(&png_path, bytes).await?;
Ok(png_path)
}
}
#[macro_export]
macro_rules! system_test {
($builder:expr) => {{
$builder
.build()
.await
.expect("system_test! failed to start runner")
}};
}
fn js_string_literal(s: &str) -> String {
let escaped = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r");
format!("\"{escaped}\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn js_string_literal_escapes_quotes() {
assert_eq!(js_string_literal(r#"say "hi""#), r#""say \"hi\"""#);
}
#[test]
fn js_string_literal_escapes_backslashes() {
assert_eq!(js_string_literal(r"a\b"), r#""a\\b""#);
}
#[test]
fn artifact_dir_contains_test_name() {
let d = artifact_dir("my_test_name");
assert!(d.to_string_lossy().contains("my_test_name"));
assert!(d.to_string_lossy().contains("system-tests"));
}
fn chrome_err(message: &str) -> chromiumoxide::error::CdpError {
chromiumoxide::error::CdpError::Chrome(chromiumoxide::types::Error {
code: -32000,
message: message.to_owned(),
})
}
#[test]
fn transient_navigation_errors_are_recognised() {
for message in [
"Cannot find context with specified id",
"Execution context was destroyed.",
"Inspected target navigated or closed",
"cannot find CONTEXT with specified id",
] {
assert!(
is_transient_navigation_error(&chrome_err(message)),
"{message:?} means the page navigated mid-poll and must be \
retried, not propagated"
);
}
}
#[test]
fn transient_detection_covers_untyped_chrome_messages() {
assert!(
is_transient_navigation_error(&chromiumoxide::error::CdpError::msg(
"Cannot find context with specified id"
)),
"the untyped ChromeMessage variant carries the same transient errors"
);
}
#[test]
fn genuine_cdp_errors_are_not_swallowed() {
for message in [
"No node with given id found",
"Could not compute content quads.",
"Node is not a HTMLElement",
"ReferenceError: context is not defined",
] {
assert!(
!is_transient_navigation_error(&chrome_err(message)),
"{message:?} is a real failure and must propagate immediately"
);
}
assert!(
!is_transient_navigation_error(&chromiumoxide::error::CdpError::Timeout),
"a CDP timeout is not a navigation race"
);
assert!(
!is_transient_navigation_error(&chromiumoxide::error::CdpError::NoResponse),
"a dead connection is not a navigation race"
);
}
#[test]
fn a_dead_target_is_not_treated_as_a_navigation() {
for message in ["Target closed", "Session with given id not found."] {
assert!(
!is_transient_navigation_error(&chrome_err(message)),
"{message:?} is indistinguishable from a dead renderer and must \
surface immediately"
);
}
}
fn scripted_probe(
script: Vec<Result<bool, chromiumoxide::error::CdpError>>,
) -> (
impl FnMut() -> std::future::Ready<Result<bool, chromiumoxide::error::CdpError>>,
Arc<std::sync::atomic::AtomicUsize>,
) {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = Arc::clone(&calls);
let script = Arc::new(script);
let probe = move || {
let n = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let step = script.get(n).unwrap_or_else(|| script.last().unwrap());
let replay = match step {
Ok(v) => Ok(*v),
Err(e) => Err(chromiumoxide::error::CdpError::msg(e.to_string())),
};
std::future::ready(replay)
};
(probe, calls)
}
#[tokio::test(start_paused = true)]
async fn polling_retries_past_transient_errors_and_succeeds() {
let (probe, calls) = scripted_probe(vec![
Err(chrome_err("Cannot find context with specified id")),
Err(chrome_err("Cannot find context with specified id")),
Ok(true),
]);
let deadline = tokio::time::Instant::now() + ASSERTION_TIMEOUT;
let outcome = poll_until_deadline(deadline, false, probe).await;
assert!(
matches!(outcome, Ok(true)),
"a transient error must not abort the poll; got {outcome:?}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::Relaxed),
3,
"the loop must have polled past both transient errors"
);
}
#[tokio::test(start_paused = true)]
async fn polling_aborts_immediately_on_a_genuine_error() {
let (probe, calls) = scripted_probe(vec![Err(chrome_err("No node with given id found"))]);
let deadline = tokio::time::Instant::now() + ASSERTION_TIMEOUT;
let outcome = poll_until_deadline(deadline, false, probe).await;
assert!(
matches!(outcome, Err(SystemTestError::Browser(_))),
"a genuine CDP error must propagate rather than decay into a \
timeout; got {outcome:?}"
);
assert_eq!(
calls.load(std::sync::atomic::Ordering::Relaxed),
1,
"it must fail fast, not keep polling"
);
}
#[tokio::test(start_paused = true)]
async fn polling_gives_up_at_the_deadline() {
let (probe, _) = scripted_probe(vec![Err(chrome_err(
"Cannot find context with specified id",
))]);
let deadline = tokio::time::Instant::now() + ASSERTION_TIMEOUT;
let outcome = poll_until_deadline(deadline, false, probe).await;
assert!(
matches!(outcome, Ok(false)),
"the caller must get the deadline signal so it can write artifacts \
and report a real assertion failure; got {outcome:?}"
);
assert!(
tokio::time::Instant::now() >= deadline,
"it must actually have waited out the assertion timeout"
);
}
#[tokio::test(start_paused = true)]
async fn settle_treats_a_navigated_page_as_settled() {
let (probe, calls) =
scripted_probe(vec![Err(chrome_err("Execution context was destroyed."))]);
let deadline = tokio::time::Instant::now() + DEFAULT_HX_SETTLE_TIMEOUT;
let outcome = poll_until_deadline(deadline, true, probe).await;
assert!(matches!(outcome, Ok(true)), "got {outcome:?}");
assert_eq!(
calls.load(std::sync::atomic::Ordering::Relaxed),
1,
"settle must not keep polling a destroyed context"
);
}
type IngressLog = Arc<Mutex<Vec<&'static str>>>;
#[derive(Clone)]
struct TenantId(&'static str);
fn recording_layer(
log: &IngressLog,
name: &'static str,
out: &'static str,
) -> impl crate::app::IntoAppLayer + Clone {
let log = Arc::clone(log);
axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let log = Arc::clone(&log);
async move {
log.lock().unwrap().push(name);
let response = next.run(req).await;
log.lock().unwrap().push(out);
response
}
},
)
}
fn tenant_echo_route() -> Route {
Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(|tenant: Option<axum::Extension<TenantId>>| async move {
tenant.map_or_else(
|| "no-tenant".to_owned(),
|axum::Extension(t)| format!("tenant={}", t.0),
)
}),
name: "tenant_echo",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/",
operation_id: "tenant_echo",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}
}
async fn probe_router(router: axum::Router) -> (axum::http::StatusCode, String) {
use tower::ServiceExt as _;
let request = axum::http::Request::builder()
.uri("/")
.body(axum::body::Body::empty())
.unwrap();
let response = router.oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
fn tenant_layer() -> impl crate::app::IntoAppLayer + Clone {
axum::middleware::from_fn(
|mut req: axum::extract::Request, next: axum::middleware::Next| async move {
req.extensions_mut().insert(TenantId("acme-corp"));
next.run(req).await
},
)
}
#[tokio::test]
async fn registered_layer_reaches_registered_route_handlers() {
let builder = SystemTest::new()
.routes(vec![tenant_echo_route()])
.layer(tenant_layer());
let (status, body) = probe_router(builder.into_router()).await;
assert_eq!(status, axum::http::StatusCode::OK);
assert_eq!(
body, "tenant=acme-corp",
"the handler must observe the extension inserted by the layer \
registered via SystemTest::layer"
);
}
#[tokio::test]
async fn without_a_layer_the_same_route_sees_nothing() {
let builder = SystemTest::new().routes(vec![tenant_echo_route()]);
let (_, body) = probe_router(builder.into_router()).await;
assert_eq!(body, "no-tenant");
}
#[tokio::test]
async fn registered_layer_runs_on_the_state_override_path() {
let state = crate::state::AppState::for_test();
state.insert_extension(AutumnConfig::default());
let builder = SystemTest::new()
.routes(vec![tenant_echo_route()])
.state(state)
.layer(tenant_layer());
let (_, body) = probe_router(builder.into_router()).await;
assert_eq!(body, "tenant=acme-corp");
}
#[tokio::test]
async fn a_layer_can_short_circuit_the_handler() {
let builder =
SystemTest::new()
.routes(vec![tenant_echo_route()])
.layer(axum::middleware::from_fn(
|_req: axum::extract::Request, _next: axum::middleware::Next| async move {
axum::http::StatusCode::IM_A_TEAPOT
},
));
let (status, body) = probe_router(builder.into_router()).await;
assert_eq!(status, axum::http::StatusCode::IM_A_TEAPOT);
assert!(
!body.contains("tenant"),
"the handler must not have run; got body {body:?}"
);
}
#[tokio::test]
async fn layers_compose_first_registered_outermost() {
let log: IngressLog = Arc::new(Mutex::new(Vec::new()));
let builder = SystemTest::new()
.routes(vec![tenant_echo_route()])
.layer(recording_layer(&log, "first", "first:out"))
.layer(recording_layer(&log, "second", "second:out"));
probe_router(builder.into_router()).await;
assert_eq!(
*log.lock().unwrap(),
vec!["first", "second", "second:out", "first:out"],
"ordering must match AppBuilder::layer: the first registration is \
the outermost layer, so it sees the request first and the \
response last"
);
}
#[tokio::test]
async fn a_registered_layer_observes_the_framework_request_id() {
let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let sink = Arc::clone(&seen);
let builder =
SystemTest::new()
.routes(vec![tenant_echo_route()])
.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let sink = Arc::clone(&sink);
async move {
let id = req
.extensions()
.get::<crate::middleware::RequestId>()
.map(ToString::to_string);
*sink.lock().unwrap() = id;
next.run(req).await
}
},
));
probe_router(builder.into_router()).await;
let observed = seen.lock().unwrap().clone();
assert!(
observed.is_some_and(|id| !id.is_empty()),
"a layer registered on SystemTest must see the request ID, as it \
does under AppBuilder::layer"
);
}
#[test]
fn build_router_default_state_does_not_panic() {
let _router = SystemTest::new().into_router();
}
#[test]
fn build_router_with_state_override_uses_embedded_config() {
let config = AutumnConfig {
profile: Some("custom".into()),
..Default::default()
};
let state = crate::state::AppState::for_test();
state.insert_extension(config);
let _router = SystemTest::new().state(state).into_router();
}
#[test]
fn build_router_with_state_override_no_embedded_config_uses_default() {
let state = crate::state::AppState::for_test();
let _router = SystemTest::new().state(state).into_router();
}
}