#![cfg(feature = "system-tests")]
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
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);
#[derive(Debug, Clone)]
pub enum BrowserCheck {
Found {
path: PathBuf,
version: String,
},
NotFound {
searched_paths: Vec<PathBuf>,
},
}
impl BrowserCheck {
#[must_use]
pub fn run() -> Self {
let candidates = browser_candidates();
let mut searched = Vec::new();
for path in &candidates {
if path.is_file()
&& let Some(version) = probe_version(path)
{
return Self::Found {
path: path.clone(),
version,
};
}
searched.push(path.clone());
}
Self::NotFound {
searched_paths: searched,
}
}
#[must_use]
pub const fn is_found(&self) -> bool {
matches!(self, Self::Found { .. })
}
}
impl fmt::Display for BrowserCheck {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Found { path, version } => {
write!(f, "Chromium found: {} ({})", path.display(), version)
}
Self::NotFound { searched_paths } => {
write!(
f,
"Chromium not found. Searched:\n{}",
searched_paths
.iter()
.map(|p| format!(" {}", p.display()))
.collect::<Vec<_>>()
.join("\n")
)?;
write!(
f,
"\n\nTo install on Ubuntu/Debian: apt-get install chromium-browser\n\
Or set the AUTUMN_CHROMIUM environment variable to the full binary path."
)
}
}
}
}
#[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),
}
#[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>,
#[allow(dead_code)]
config: AutumnConfig,
artifact_dir_override: Option<PathBuf>,
browser_timeout: Duration,
hx_settle_timeout: Duration,
state_override: Option<crate::state::AppState>,
}
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,
}
}
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 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 browser_path = find_chromium().ok_or_else(|| {
let searched = browser_candidates();
SystemTestError::BrowserNotFound { searched }
})?;
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 router = build_router_for_system_test(self.routes, self.state_override);
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 config = BrowserConfig::builder()
.chrome_executable(browser_path)
.arg("--no-sandbox")
.arg("--disable-dev-shm-usage")
.arg("--disable-gpu")
.arg("--headless")
.launch_timeout(self.browser_timeout)
.build()
.map_err(|msg| SystemTestError::Browser(chromiumoxide::error::CdpError::msg(msg)))?;
let (browser, handler) =
tokio::time::timeout(self.browser_timeout, Browser::launch(config))
.await
.map_err(|_| SystemTestError::Timeout {
message: "timed out waiting for Chromium to launch".into(),
timeout: self.browser_timeout,
})??;
tokio::spawn(async move {
handler.for_each(|_| async {}).await;
});
let artifact_dir = self.artifact_dir_override.unwrap_or_else(|| {
let name = std::thread::current()
.name()
.unwrap_or("system_test")
.replace("::", "__");
artifact_dir(&name)
});
Ok(SystemTestRunner {
base_url,
browser,
artifact_dir,
hx_settle_timeout: self.hx_settle_timeout,
_shutdown: shutdown_tx,
_server_handle: server_handle,
})
}
}
pub struct SystemTestRunner {
base_url: String,
browser: Browser,
artifact_dir: PathBuf,
hx_settle_timeout: Duration,
_shutdown: tokio::sync::oneshot::Sender<()>,
_server_handle: tokio::task::JoinHandle<()>,
}
impl SystemTestRunner {
pub async fn page(&self) -> Result<Page, SystemTestError> {
let cdp_page = self.browser.new_page("about:blank").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,
})
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
}
pub struct Page {
inner: chromiumoxide::page::Page,
base_url: String,
artifact_dir: PathBuf,
hx_settle_timeout: Duration,
}
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 timeout = Duration::from_secs(5);
let deadline = tokio::time::Instant::now() + timeout;
loop {
let result = self
.inner
.evaluate(format!(
"document.body && document.body.innerText.includes({})",
js_string_literal(text)
))
.await?;
let found: bool = result.into_value().unwrap_or(false);
if found {
return Ok(self);
}
if tokio::time::Instant::now() >= deadline {
let artifact = self.write_failure_artifacts("expect_text").await.ok();
return Err(SystemTestError::AssertionFailed {
message: format!("expected text {text:?} in page body"),
artifact_path: artifact,
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn expect_url(&self, pattern: &str) -> Result<&Self, SystemTestError> {
let timeout = Duration::from_secs(5);
let deadline = tokio::time::Instant::now() + timeout;
loop {
let result = self
.inner
.evaluate(format!(
"window.location.href.includes({})",
js_string_literal(pattern)
))
.await?;
let found: bool = result.into_value().unwrap_or(false);
if found {
return Ok(self);
}
if tokio::time::Instant::now() >= deadline {
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();
return Err(SystemTestError::AssertionFailed {
message: format!("expected URL to contain {pattern:?}, got {current_url:?}"),
artifact_path: artifact,
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn expect_attribute(
&self,
selector: &str,
attr: &str,
value: &str,
) -> Result<&Self, SystemTestError> {
let timeout = Duration::from_secs(5);
let deadline = tokio::time::Instant::now() + timeout;
loop {
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),
);
let result = self.inner.evaluate(js).await?;
let found: bool = result.into_value().unwrap_or(false);
if found {
return Ok(self);
}
if tokio::time::Instant::now() >= deadline {
let artifact = self.write_failure_artifacts("expect_attribute").await.ok();
return Err(SystemTestError::AssertionFailed {
message: format!("expected [{attr}={value:?}] on {selector:?}"),
artifact_path: artifact,
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn expect_hx_settle(&self) -> Result<&Self, SystemTestError> {
self.wait_for_hx_settle().await?;
Ok(self)
}
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 result = self.inner.evaluate(js).await?;
let text: Option<String> = result.into_value().ok();
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;
loop {
let result = self
.inner
.evaluate("document.querySelectorAll('.htmx-request,.htmx-settling,.htmx-swapping').length === 0")
.await?;
let settled: bool = result.into_value().unwrap_or(true);
if settled {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(SystemTestError::Timeout {
message: "htmx did not settle".into(),
timeout: self.hx_settle_timeout,
});
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
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 browser_candidates() -> Vec<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(p) = std::env::var("AUTUMN_CHROMIUM") {
candidates.push(PathBuf::from(p));
}
if let Ok(base) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
let base = PathBuf::from(base);
if let Ok(entries) = std::fs::read_dir(&base) {
let mut pw_paths: Vec<PathBuf> = entries
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with("chromium-"))
.map(|e| {
if cfg!(target_os = "macos") {
e.path()
.join("chrome-mac")
.join("Chromium.app")
.join("Contents")
.join("MacOS")
.join("Chromium")
} else if cfg!(target_os = "windows") {
e.path().join("chrome-win").join("chrome.exe")
} else {
e.path().join("chrome-linux").join("chrome")
}
})
.collect();
pw_paths.sort();
pw_paths.reverse(); candidates.extend(pw_paths);
}
}
for name in &[
"chrome",
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
] {
if let Some(p) = std::env::var_os("PATH").and_then(|path_var| {
std::env::split_paths(&path_var)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
}) {
candidates.push(p);
}
}
candidates.extend(
[
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
.map(PathBuf::from),
);
candidates
}
fn find_chromium() -> Option<PathBuf> {
browser_candidates()
.into_iter()
.find(|path| path.is_file() && probe_version(path).is_some())
}
fn probe_version(path: &Path) -> Option<String> {
std::process::Command::new(path)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
}
fn build_router_for_system_test(
routes: Vec<Route>,
state_override: Option<crate::state::AppState>,
) -> axum::Router {
if let Some(state) = state_override {
let config = state
.extension::<AutumnConfig>()
.map(|arc| (*arc).clone())
.unwrap_or_default();
crate::router::build_router(routes, &config, state)
} else {
let mut security = crate::security::SecurityConfig::default();
security.csrf.enabled = false;
let config = AutumnConfig {
profile: Some("test".into()),
security,
..Default::default()
};
let state = crate::state::AppState::for_test().with_profile("test");
state.insert_extension(config.clone());
crate::router::build_router(routes, &config, state)
}
}
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"));
}
#[test]
fn browser_check_not_found_message_has_hints() {
let check = BrowserCheck::NotFound {
searched_paths: vec![PathBuf::from("/no/such/path")],
};
let msg = check.to_string();
assert!(msg.contains("apt-get") || msg.contains("AUTUMN_CHROMIUM"));
}
#[test]
fn browser_candidates_includes_common_paths() {
let candidates = browser_candidates();
let as_strings: Vec<_> = candidates
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(
as_strings
.iter()
.any(|s| s.contains("chromium") || s.contains("chrome")),
"should have at least one chrome path; got {as_strings:?}"
);
}
#[test]
fn build_router_default_state_does_not_panic() {
let _router = build_router_for_system_test(vec![], None);
}
#[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 = build_router_for_system_test(vec![], Some(state));
}
#[test]
fn build_router_with_state_override_no_embedded_config_uses_default() {
let state = crate::state::AppState::for_test();
let _router = build_router_for_system_test(vec![], Some(state));
}
}