use crate::common::{TestConfig, TestResult, TestRunner};
use crate::error::Result;
use async_trait::async_trait;
use chrono::Utc;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use log::{info, warn};
#[derive(Debug, Serialize, Deserialize)]
pub struct WebTestConfig {
#[serde(flatten)]
pub base: TestConfig,
pub target_url: String,
pub viewport: Option<Viewport>,
pub wait_for_selector: Option<String>,
pub wait_timeout_secs: Option<u64>,
pub screenshots: Option<bool>,
pub assertions: Option<Vec<WebAssertion>>,
pub actions: Option<Vec<WebAction>>,
#[serde(default = "default_user_agent")]
pub user_agent: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Viewport {
pub width: u32,
pub height: u32,
pub device_scale_factor: Option<f64>,
pub is_mobile: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct WebAssertion {
pub assertion_type: String, pub selector: Option<String>,
pub attribute: Option<String>,
pub expected_value: String,
pub comparison: Option<String>, }
#[derive(Debug, Serialize, Deserialize)]
pub struct WebAction {
pub action_type: String, pub selector: Option<String>,
pub value: Option<String>,
pub wait_time_ms: Option<u64>,
}
fn default_user_agent() -> String {
"QitOps-WebTester/1.0".to_string()
}
pub struct WebTestRunner {
client: Client,
headless: bool,
screenshot_dir: Option<String>,
}
impl WebTestRunner {
pub fn new(headless: bool, screenshot_dir: Option<String>) -> Self {
Self {
client: Client::new(),
headless,
screenshot_dir,
}
}
async fn run_web_test(&self, config: &WebTestConfig) -> Result<serde_json::Value> {
if self.headless {
info!("Starting web test in headless mode for URL: {}", config.target_url);
} else {
info!("Starting web test with visible browser for URL: {}", config.target_url);
}
let response = self.client
.get(&config.target_url)
.header("User-Agent", &config.user_agent)
.timeout(Duration::from_secs(config.wait_timeout_secs.unwrap_or(30)))
.send()
.await?;
let status = response.status();
let _headers = response.headers().clone();
let body = response.text().await?;
let mut assertion_results = Vec::new();
if let Some(assertions) = &config.assertions {
for assertion in assertions {
let result = self.evaluate_assertion(assertion, &body, &config.target_url).await?;
assertion_results.push(result);
}
}
let mut action_results = Vec::new();
if let Some(actions) = &config.actions {
for action in actions {
let result = self.simulate_action(action, &config.target_url).await?;
action_results.push(result);
}
}
let screenshot_path = if config.screenshots.unwrap_or(false) {
if let Some(dir) = &self.screenshot_dir {
let timestamp = Utc::now().timestamp();
let filename = format!("{}/screenshot_{}.png", dir, timestamp);
if self.headless {
info!("Taking screenshot in headless mode");
} else {
info!("Taking screenshot in visible browser mode");
}
Some(filename)
} else {
None
}
} else {
None
};
Ok(serde_json::json!({
"page_title": "Simulated Page Title",
"status_code": status.as_u16(),
"content_length": body.len(),
"assertion_results": assertion_results,
"action_results": action_results,
"screenshot": screenshot_path,
}))
}
async fn evaluate_assertion(&self, assertion: &WebAssertion, body: &str, url: &str) -> Result<serde_json::Value> {
let (passed, details) = match assertion.assertion_type.as_str() {
"title" => {
let simulated_title = "Simulated Page Title";
let passed = match assertion.comparison.as_deref().unwrap_or("equals") {
"equals" => simulated_title == assertion.expected_value,
"contains" => simulated_title.contains(&assertion.expected_value),
"startsWith" => simulated_title.starts_with(&assertion.expected_value),
"endsWith" => simulated_title.ends_with(&assertion.expected_value),
"matches" => false, _ => false,
};
(passed, format!("Title: {}", simulated_title))
},
"url" => {
let passed = match assertion.comparison.as_deref().unwrap_or("equals") {
"equals" => url == assertion.expected_value,
"contains" => url.contains(&assertion.expected_value),
"startsWith" => url.starts_with(&assertion.expected_value),
"endsWith" => url.ends_with(&assertion.expected_value),
"matches" => false, _ => false,
};
(passed, format!("URL: {}", url))
},
"element" => {
let selector = assertion.selector.as_deref().unwrap_or("");
let element_exists = body.contains(selector); (element_exists, format!("Element with selector '{}' exists: {}", selector, element_exists))
},
"text" => {
let text_exists = body.contains(&assertion.expected_value);
(text_exists, format!("Text '{}' exists: {}", assertion.expected_value, text_exists))
},
_ => (false, "Unsupported assertion type".to_string()),
};
Ok(serde_json::json!({
"type": assertion.assertion_type,
"passed": passed,
"details": details
}))
}
async fn simulate_action(&self, action: &WebAction, url: &str) -> Result<serde_json::Value> {
match action.action_type.as_str() {
"click" => {
let selector = action.selector.as_deref().unwrap_or("");
info!("Simulating click on element with selector: {}", selector);
Ok(serde_json::json!({
"type": "click",
"selector": selector,
"success": true
}))
},
"type" => {
let selector = action.selector.as_deref().unwrap_or("");
let value = action.value.as_deref().unwrap_or("");
info!("Simulating typing '{}' into element with selector: {}", value, selector);
Ok(serde_json::json!({
"type": "type",
"selector": selector,
"value": value,
"success": true
}))
},
"wait" => {
let wait_time = action.wait_time_ms.unwrap_or(1000);
info!("Simulating wait for {} ms", wait_time);
tokio::time::sleep(Duration::from_millis(wait_time)).await;
Ok(serde_json::json!({
"type": "wait",
"duration_ms": wait_time,
"success": true
}))
},
"navigate" => {
let target_url = action.value.as_deref().unwrap_or(url);
info!("Simulating navigation to: {}", target_url);
Ok(serde_json::json!({
"type": "navigate",
"url": target_url,
"success": true
}))
},
_ => {
warn!("Unsupported action type: {}", action.action_type);
Ok(serde_json::json!({
"type": action.action_type,
"success": false,
"error": "Unsupported action type"
}))
}
}
}
}
#[async_trait]
impl TestRunner for WebTestRunner {
async fn run(&self, config: &(impl serde::Serialize + Send + Sync)) -> Result<TestResult> {
let config = serde_json::from_value::<WebTestConfig>(serde_json::to_value(config)?)?;
let start = Instant::now();
match self.run_web_test(&config).await {
Ok(details) => {
let duration = start.elapsed().as_secs_f64();
let empty_vec = Vec::new();
let assertions = details["assertion_results"].as_array().unwrap_or(&empty_vec);
let all_assertions_passed = assertions.iter().all(|a| a["passed"].as_bool().unwrap_or(false));
let status = if all_assertions_passed {
"passed".to_string()
} else {
"failed".to_string()
};
Ok(TestResult {
name: config.base.name,
status,
duration,
details: Some(details),
timestamp: Utc::now().to_rfc3339(),
})
},
Err(e) => {
let duration = start.elapsed().as_secs_f64();
Ok(TestResult {
name: config.base.name,
status: "error".to_string(),
duration,
details: Some(serde_json::json!({
"error": e.to_string()
})),
timestamp: Utc::now().to_rfc3339(),
})
}
}
}
}