use std::collections::HashMap;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize)]
pub struct Scenario {
#[serde(default)]
pub config: ScenarioConfig,
#[serde(default)]
pub definitions: Vec<AssertDefinition>,
#[serde(default)]
pub test: Vec<TestGroup>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct ScenarioConfig {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub llm_url: Option<String>,
#[serde(default)]
pub llm_model: Option<String>,
#[serde(default)]
pub llm_api_key: Option<String>,
#[serde(default, deserialize_with = "deserialize_headers")]
pub llm_headers: HashMap<String, String>,
#[serde(default)]
pub browser_headless: Option<bool>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub viewport_width: Option<u32>,
#[serde(default)]
pub viewport_height: Option<u32>,
#[serde(default)]
pub start_url: Option<String>,
#[serde(default = "default_auto_navigate")]
pub auto_navigate: bool,
#[serde(default = "default_temperature")]
pub temperature: f64,
#[serde(default)]
pub thinking: Option<bool>,
#[serde(default, deserialize_with = "deserialize_model_params")]
pub model_params: HashMap<String, Value>,
#[serde(default)]
pub endpoints: HashMap<String, EndpointConfig>,
#[serde(default)]
pub budgets: BudgetsConfig,
#[serde(default)]
pub mcp_server: Option<McpServerConfig>,
#[serde(default)]
pub a2a_server: Option<A2aServerConfig>,
#[serde(default)]
pub continue_on_failure: bool,
#[serde(default)]
pub screenshot_max_dimension: Option<u32>,
#[serde(default, deserialize_with = "deserialize_screenshot_max_height")]
pub screenshot_max_height: Option<ScreenshotHeight>,
#[serde(default)]
pub artifacts_dir: Option<String>,
#[serde(default)]
pub viewport_matrix: Option<ViewportMatrix>,
#[serde(default = "default_layout_ignore_classes")]
pub layout_ignore_classes: Vec<String>,
#[serde(default)]
pub concurrency_group: Option<String>,
#[serde(default)]
pub cache: Option<bool>,
#[serde(default)]
pub cache_pricing: Option<bool>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct ViewportMatrix {
#[serde(default)]
pub viewports: Vec<ViewportDef>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ScreenshotHeight {
Pixels(u32),
ViewportTimes(f64),
}
const MAX_SCREENSHOT_HEIGHT: u32 = 4_000_000;
impl ScreenshotHeight {
#[must_use]
pub fn to_px(&self, viewport_height: u32) -> u32 {
match self {
Self::Pixels(px) => *px,
Self::ViewportTimes(mult) => {
let scaled = f64::from(viewport_height) * mult;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let px = scaled
.round()
.max(1.0)
.min(f64::from(MAX_SCREENSHOT_HEIGHT)) as u32;
px
}
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct ViewportDef {
pub name: String,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct EndpointConfig {
#[serde(rename = "type")]
pub endpoint_type: EndpointType,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default, deserialize_with = "deserialize_headers")]
pub headers: HashMap<String, String>,
#[serde(default)]
pub pricing: Option<PricingConfig>,
#[serde(default)]
pub pricing_source: Option<String>,
#[serde(default)]
pub default_for: Vec<String>,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub vision: bool,
#[serde(default)]
pub max_attempts: Option<u32>,
#[serde(default)]
pub fallbacks: Vec<String>,
#[serde(default)]
pub provider: Provider,
#[serde(default)]
pub deployment: Option<String>,
#[serde(default)]
pub api_version: Option<String>,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default, deserialize_with = "deserialize_headers")]
pub header_commands: HashMap<String, String>,
#[serde(default)]
pub aws: AwsConfig,
#[serde(default)]
pub cache: Option<bool>,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum EndpointType {
#[default]
Llm,
Mcp,
A2a,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
#[default]
Openai,
Azure,
Bedrock,
}
#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum AuthMode {
#[default]
ApiKey,
TokenCommand,
EntraClientCredentials,
EntraManagedIdentity,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct AuthConfig {
#[serde(default)]
pub mode: AuthMode,
#[serde(default)]
pub api_key_header: Option<String>,
#[serde(default)]
pub token_command: Option<String>,
#[serde(default)]
pub tenant_id: Option<String>,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub client_secret: Option<String>,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub token_url: Option<String>,
#[serde(default)]
pub cache_ttl_secs: Option<u64>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct AwsConfig {
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub access_key_id: Option<String>,
#[serde(default)]
pub secret_access_key: Option<String>,
#[serde(default)]
pub session_token: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct PricingConfig {
#[serde(default)]
pub input_per_1m_tokens: f64,
#[serde(default)]
pub output_per_1m_tokens: f64,
#[serde(default)]
pub per_call: f64,
#[serde(default)]
pub cached_input_per_1m_tokens: Option<f64>,
#[serde(default)]
pub cache_write_per_1m_tokens: Option<f64>,
#[serde(default)]
pub cache_read_multiplier: Option<f64>,
#[serde(default)]
pub cache_write_multiplier: Option<f64>,
#[serde(default)]
pub cache_pricing: Option<bool>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct BudgetsConfig {
#[serde(default)]
pub global: Option<BudgetDef>,
#[serde(default)]
pub per_test_default: Option<BudgetDef>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct BudgetDef {
#[serde(default)]
pub max_cost: Option<f64>,
#[serde(default)]
pub max_tokens: Option<u64>,
#[serde(default)]
pub max_calls: Option<u64>,
#[serde(default)]
pub enforcement: Option<BudgetEnforcement>,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetEnforcement {
Hard,
Soft,
}
#[derive(Debug, Deserialize, Clone)]
pub struct McpServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_mcp_port")]
pub port: u16,
}
const fn default_mcp_port() -> u16 {
3000
}
#[derive(Debug, Deserialize, Clone)]
pub struct A2aServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_a2a_port")]
pub port: u16,
}
const fn default_a2a_port() -> u16 {
3100
}
fn deserialize_headers<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
let Some(json) = raw else {
return Ok(HashMap::new());
};
let serde_json::Value::Object(obj) = json else {
return Ok(HashMap::new());
};
Ok(obj
.into_iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_owned())))
.collect())
}
const fn default_auto_navigate() -> bool {
true
}
fn default_layout_ignore_classes() -> Vec<String> {
vec![
"cdk-visually-hidden".to_owned(),
"cdk-describedby-message-container".to_owned(),
"cdk-overlay-container".to_owned(),
]
}
const fn default_temperature() -> f64 {
0.0
}
fn deserialize_model_params<'de, D>(deserializer: D) -> Result<HashMap<String, Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Map(HashMap<String, Value>),
Table(HashMap<String, Value>),
}
let raw: Option<Raw> = Option::deserialize(deserializer)?;
Ok(match raw {
Some(Raw::Map(m) | Raw::Table(m)) => m,
None => HashMap::new(),
})
}
fn deserialize_screenshot_max_height<'de, D>(
deserializer: D,
) -> Result<Option<ScreenshotHeight>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
match raw {
None => Ok(None),
Some(value) => match value.as_u64() {
Some(px) if px <= u64::from(MAX_SCREENSHOT_HEIGHT) => {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Ok(Some(ScreenshotHeight::Pixels(px as u32)))
}
None => match value.as_str() {
Some(s) => {
let lower = s.trim().to_lowercase();
if lower.ends_with('x') {
let num = lower.strip_suffix("x");
if let Some(num) = num {
if let Ok(m) = num.parse::<f64>() {
if m > 0.0 {
return Ok(Some(ScreenshotHeight::ViewportTimes(m)));
}
}
}
}
Err(serde::de::Error::custom(format_args!(
"screenshot_max_height must be a pixel count (e.g. 2880) or a viewport multiple like \"2x\", found {s:?}"
)))
}
None => Err(serde::de::Error::custom(format_args!(
"screenshot_max_height must be a pixel count (e.g. 2880) or a viewport multiple like \"2x\", found {value:?}"
))),
},
Some(_) => Err(serde::de::Error::custom(format_args!(
"screenshot_max_height value too large (max {MAX_SCREENSHOT_HEIGHT} px)"
))),
},
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct AssertDefinition {
pub name: String,
#[serde(default)]
pub preset: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub system: Option<String>,
#[serde(default)]
pub user_template: Option<String>,
#[serde(default)]
pub assert_text: Option<String>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub task_template: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct TestGroup {
pub name: String,
#[serde(default)]
pub start_url: Option<String>,
#[serde(default)]
pub auto_navigate: Option<bool>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub browser_headless: Option<bool>,
#[serde(default)]
pub viewport_width: Option<u32>,
#[serde(default)]
pub viewport_height: Option<u32>,
#[serde(default)]
pub budget: Option<BudgetDef>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub steps: Vec<TestStep>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "kind")]
pub enum TestStep {
#[serde(rename = "navigate")]
Navigate {
url: String,
#[serde(default)]
wait_after_ms: Option<u64>,
},
#[serde(rename = "click")]
Click {
target: String,
#[serde(default)]
selector: Option<String>,
#[serde(default)]
wait_after_ms: Option<u64>,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
idempotent: bool,
},
#[serde(rename = "type")]
Type {
target: String,
text: String,
#[serde(default)]
selector: Option<String>,
#[serde(default)]
wait_after_ms: Option<u64>,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
idempotent: bool,
},
#[serde(rename = "wait")]
Wait {
target: String,
#[serde(default)]
selector: Option<String>,
#[serde(default)]
text: Option<String>,
#[serde(default)]
timeout_ms: Option<u64>,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
idempotent: bool,
},
#[serde(rename = "assert")]
Assert {
#[serde(default)]
definition: Option<String>,
#[serde(default)]
preset: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
assert_text: Option<String>,
#[serde(default)]
endpoint: Option<String>,
#[serde(default)]
screenshot: bool,
},
#[serde(rename = "screenshot")]
Screenshot {
#[serde(default)]
path: Option<String>,
},
#[serde(rename = "agent")]
Agent {
agent: String,
task: String,
#[serde(default)]
definition: Option<String>,
},
#[serde(rename = "mcp")]
Mcp {
server: String,
tool: String,
#[serde(default)]
args: Option<serde_json::Value>,
},
}