use crate::core::config::e2e::ArgMapping;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
pub mod docs_only;
mod docs_presentation;
mod loader;
mod metadata;
mod protocol;
pub use loader::load_fixtures;
pub use metadata::{
FixtureDocs, FixtureDocsClient, FixtureDocsFileInput, FixtureDocsOperation, FixtureDocsPresentation, FixtureEnv,
SetupCall, SideEffectClass, SnippetCoverageException, TemplateReturnForm,
};
pub use protocol::{
AsyncApiFixture, WebSocketFixture, WebSocketFrameType, WebSocketHandler, WebSocketMessage,
WebSocketMessageDirection, WebSocketSession,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockResponse {
pub status: u16,
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub stream_chunks: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
}
pub const VISITOR_EXCLUDE_FUNCTION_NAME: &str = "visitor";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisitorSpec {
pub callbacks: BTreeMap<String, CallbackAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
pub enum CallbackAction {
#[serde(rename = "skip")]
Skip,
#[serde(rename = "continue")]
Continue,
#[serde(rename = "preserve_html")]
PreserveHtml,
#[serde(rename = "custom")]
Custom {
output: String,
},
#[serde(rename = "custom_template")]
CustomTemplate {
template: String,
#[serde(default)]
return_form: TemplateReturnForm,
},
}
impl CallbackAction {
pub fn wire_name(&self) -> &'static str {
match self {
Self::Skip => "skip",
Self::Continue => "continue",
Self::PreserveHtml => "preserve_html",
Self::Custom { .. } | Self::CustomTemplate { .. } => "custom",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Fixture {
pub id: String,
#[serde(default)]
pub category: Option<String>,
pub description: String,
#[serde(default)]
pub docs: Option<FixtureDocs>,
#[serde(default)]
pub requirements: Vec<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub skip: Option<SkipDirective>,
#[serde(default)]
pub env: Option<FixtureEnv>,
#[serde(default)]
pub setup: Vec<SetupCall>,
#[serde(default)]
pub call: Option<String>,
#[serde(default)]
pub input: serde_json::Value,
#[serde(default)]
pub preserve_input_urls: bool,
#[serde(default)]
pub mock_response: Option<MockResponse>,
#[serde(default)]
pub visitor: Option<VisitorSpec>,
#[serde(default)]
pub args: Vec<ArgMapping>,
#[serde(default)]
pub assertion_recipes: Vec<String>,
#[serde(default)]
pub assertions: Vec<Assertion>,
#[serde(skip)]
pub source: String,
#[serde(default)]
pub http: Option<HttpFixture>,
#[serde(default)]
pub asyncapi: Option<AsyncApiFixture>,
#[serde(default)]
pub websocket: Option<WebSocketFixture>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpFixture {
pub handler: HttpHandler,
pub request: HttpRequest,
pub expected_response: HttpExpectedResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpHandler {
pub route: String,
pub method: String,
#[serde(default)]
pub body_schema: Option<serde_json::Value>,
#[serde(default)]
pub parameters: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
#[serde(default)]
pub middleware: Option<HttpMiddleware>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpRequest {
pub method: String,
pub path: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub query_params: BTreeMap<String, serde_json::Value>,
#[serde(default)]
pub cookies: BTreeMap<String, String>,
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub form_data: Option<BTreeMap<String, String>>,
#[serde(default)]
pub content_type: Option<String>,
}
impl HttpRequest {
pub fn url_encoded_body(&self) -> Option<String> {
self.form_data.as_ref().map(|form| {
form.iter()
.map(|(k, v)| {
let encoded_k = Self::url_encode(k);
let encoded_v = Self::url_encode(v);
format!("{}={}", encoded_k, encoded_v)
})
.collect::<Vec<_>>()
.join("&")
})
}
fn url_encode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
_ => format!("%{:02X}", b),
})
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpExpectedResponse {
pub status_code: u16,
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub body_partial: Option<serde_json::Value>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationErrorExpectation {
pub loc: Vec<String>,
pub msg: String,
#[serde(rename = "type")]
pub error_type: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CorsConfig {
#[serde(default)]
pub allow_origins: Vec<String>,
#[serde(default)]
pub allow_methods: Vec<String>,
#[serde(default)]
pub allow_headers: Vec<String>,
#[serde(default)]
pub expose_headers: Vec<String>,
#[serde(default)]
pub max_age: Option<u64>,
#[serde(default)]
pub allow_credentials: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticFile {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaticFilesConfig {
pub route_prefix: String,
#[serde(default)]
pub files: Vec<StaticFile>,
#[serde(default)]
pub index_file: bool,
#[serde(default)]
pub cache_control: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpMiddleware {
#[serde(default)]
pub jwt_auth: Option<serde_json::Value>,
#[serde(default)]
pub api_key_auth: Option<serde_json::Value>,
#[serde(default)]
pub compression: Option<serde_json::Value>,
#[serde(default)]
pub rate_limit: Option<serde_json::Value>,
#[serde(default)]
pub request_timeout: Option<serde_json::Value>,
#[serde(default)]
pub body_limit: Option<serde_json::Value>,
#[serde(default)]
pub request_id: Option<serde_json::Value>,
#[serde(default)]
pub cors: Option<CorsConfig>,
#[serde(default)]
pub static_files: Option<Vec<StaticFilesConfig>>,
#[serde(default)]
pub graphql: Option<serde_json::Value>,
#[serde(default)]
pub lifecycle_hooks: Option<serde_json::Value>,
#[serde(default)]
pub openrpc: Option<serde_json::Value>,
#[serde(default)]
pub background_tasks: Option<serde_json::Value>,
#[serde(default)]
pub websocket: Option<serde_json::Value>,
#[serde(default)]
pub authorization: Option<serde_json::Value>,
}
const ORIGIN_ROOT_ROUTE_PREFIXES: [&str; 2] = ["/robots", "/sitemap"];
fn is_host_root_path(path: &str) -> bool {
ORIGIN_ROOT_ROUTE_PREFIXES.iter().any(|prefix| path.starts_with(prefix))
}
impl Default for Fixture {
fn default() -> Self {
Fixture {
id: String::new(),
category: None,
description: String::new(),
docs: None,
requirements: Vec::new(),
tags: Vec::new(),
skip: None,
env: None,
setup: Vec::new(),
call: None,
input: serde_json::Value::Null,
mock_response: None,
visitor: None,
args: Vec::new(),
assertion_recipes: Vec::new(),
assertions: Vec::new(),
source: String::new(),
http: None,
asyncapi: None,
websocket: None,
preserve_input_urls: false,
}
}
}
impl Fixture {
pub fn docs_client(&self) -> Option<&FixtureDocsClient> {
self.docs.as_ref().and_then(|docs| docs.client.as_ref())
}
pub fn docs_files_for_arg(&self, field: &str) -> Vec<FixtureDocsFileInput> {
let base = if field == "input" {
if self.input.get("extract_input").is_some() {
"/extract_input".to_string()
} else {
String::new()
}
} else {
format!("/{}", field.strip_prefix("input.").unwrap_or(field).replace('.', "/"))
};
self.docs
.as_ref()
.and_then(|docs| docs.presentation.as_ref())
.map(|presentation| {
presentation
.files
.iter()
.filter_map(|file| {
file.field.strip_prefix(&base).and_then(|relative| {
(relative.is_empty() || relative.starts_with('/')).then(|| FixtureDocsFileInput {
field: relative.to_string(),
path: file.path.clone(),
})
})
})
.collect()
})
.unwrap_or_default()
}
pub fn docs_file_path(&self, field: &str) -> Option<String> {
self.docs_files_for_arg(field)
.into_iter()
.find(|file| file.field.is_empty())
.map(|file| file.path)
}
pub fn has_docs_presentation(&self) -> bool {
self.docs.as_ref().is_some_and(|docs| {
!docs.shows.is_empty()
|| docs
.presentation
.as_ref()
.is_some_and(|presentation| !presentation.operations.is_empty())
})
}
pub fn resolved_args<'a>(&'a self, call_config: &'a crate::core::config::e2e::CallConfig) -> &'a [ArgMapping] {
if !self.args.is_empty() {
&self.args
} else {
&call_config.args
}
}
pub fn is_http_test(&self) -> bool {
self.http.is_some()
}
pub fn needs_mock_server(&self) -> bool {
if self.mock_response.is_some() || self.http.is_some() {
return true;
}
self.input
.get("mock_responses")
.and_then(|v| v.as_array())
.map(|arr| !arr.is_empty())
.unwrap_or(false)
}
pub fn as_mock_response(&self) -> Option<MockResponse> {
if let Some(mock) = &self.mock_response {
return Some(mock.clone());
}
if let Some(http) = &self.http {
return Some(MockResponse {
status: http.expected_response.status_code,
body: http.expected_response.body.clone(),
stream_chunks: None,
headers: http.expected_response.headers.clone(),
});
}
None
}
pub fn is_streaming_mock(&self) -> bool {
self.mock_response
.as_ref()
.and_then(|m| m.stream_chunks.as_ref())
.map(|c| !c.is_empty())
.unwrap_or(false)
}
pub fn has_host_root_route(&self) -> bool {
if let Some(arr) = self.input.get("mock_responses").and_then(|v| v.as_array()) {
if arr.iter().any(|entry| {
entry
.get("path")
.and_then(|v| v.as_str())
.map(is_host_root_path)
.unwrap_or(false)
}) {
return true;
}
return arr.iter().any(|entry| {
let status = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(0);
let headers = entry.get("headers").and_then(|v| v.as_object());
let location_redirect = (300..400).contains(&status)
&& headers
.map(|hdrs| {
hdrs.iter().any(|(name, value)| {
name.eq_ignore_ascii_case("location")
&& value.as_str().is_some_and(|s| s.starts_with('/'))
})
})
.unwrap_or(false);
let refresh_redirect = headers
.map(|hdrs| {
hdrs.iter().any(|(name, value)| {
if !name.eq_ignore_ascii_case("refresh") {
return false;
}
value
.as_str()
.and_then(|s| s.to_ascii_lowercase().find("url=").map(|i| (s.to_owned(), i)))
.map(|(s, idx)| s[idx + 4..].trim_start().starts_with('/'))
.unwrap_or(false)
})
})
.unwrap_or(false);
let meta_refresh = entry
.get("body_inline")
.and_then(|v| v.as_str())
.map(|body| {
let lower = body.to_ascii_lowercase();
lower
.split("http-equiv=\"refresh\"")
.nth(1)
.and_then(|s| s.split("content=").nth(1))
.map(|s| s.trim_start_matches(['"', '\'']).contains("url=/"))
.unwrap_or(false)
})
.unwrap_or(false);
let inline_host_link = entry
.get("body_inline")
.and_then(|v| v.as_str())
.map(|body| body.contains("href=\"/") || body.contains("href='/"))
.unwrap_or(false);
location_redirect || refresh_redirect || meta_refresh || inline_host_link
});
}
false
}
pub fn resolved_category(&self) -> String {
self.category.clone().unwrap_or_else(|| {
Path::new(&self.source)
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("default")
.to_string()
})
}
}
const LANGUAGE_ALIASES: &[(&str, &[&str])] = &[("c", &["c_ffi", "ffi"]), ("rust", &["core", "rust_core"])];
pub fn canonical_language(language: &str) -> &str {
for (canonical, aliases) in LANGUAGE_ALIASES {
if language == *canonical || aliases.contains(&language) {
return canonical;
}
}
language
}
pub fn language_alias_groups() -> &'static [(&'static str, &'static [&'static str])] {
LANGUAGE_ALIASES
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkipDirective {
#[serde(default)]
pub languages: Vec<String>,
#[serde(default)]
pub reason: Option<String>,
}
impl SkipDirective {
pub fn should_skip(&self, language: &str) -> bool {
self.languages.is_empty()
|| self
.languages
.iter()
.any(|l| canonical_language(l) == canonical_language(language))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssertionSkipKind {
#[default]
NotRepresentable,
LanguageLimitation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AssertionSkip {
All(bool),
Scoped(AssertionSkipDirective),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AssertionSkipDirective {
#[serde(default)]
pub languages: Vec<String>,
#[serde(default)]
pub kind: AssertionSkipKind,
#[serde(default)]
pub reason: Option<String>,
}
impl AssertionSkip {
pub fn should_skip(&self, language: &str) -> bool {
match self {
Self::All(all) => *all,
Self::Scoped(directive) => {
directive.languages.is_empty()
|| directive
.languages
.iter()
.any(|l| canonical_language(l) == canonical_language(language))
}
}
}
pub fn kind(&self) -> AssertionSkipKind {
match self {
Self::All(_) => AssertionSkipKind::default(),
Self::Scoped(directive) => directive.kind,
}
}
pub fn reason(&self) -> Option<&str> {
match self {
Self::All(_) => None,
Self::Scoped(directive) => directive.reason.as_deref(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Assertion {
#[serde(rename = "type")]
pub assertion_type: String,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub value: Option<serde_json::Value>,
#[serde(default)]
pub values: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub check: Option<String>,
#[serde(default)]
pub args: Option<serde_json::Value>,
#[serde(default)]
pub return_type: Option<String>,
#[serde(default)]
pub skip: Option<AssertionSkip>,
}
impl Assertion {
pub(crate) fn expected_values(&self) -> Vec<&serde_json::Value> {
self.values
.as_ref()
.map(|values| values.iter().collect())
.or_else(|| self.value.as_ref().map(|value| vec![value]))
.unwrap_or_default()
}
}
#[derive(Debug, Clone)]
pub struct FixtureGroup {
pub category: String,
pub fixtures: Vec<Fixture>,
}
pub fn validate_skip_languages(fixtures: &[Fixture], valid_languages: &[String]) -> Result<()> {
let known_targets = crate::e2e::known_e2e_target_names();
for fixture in fixtures {
let Some(skip) = &fixture.skip else {
continue;
};
for language in &skip.languages {
let canonical = canonical_language(language);
let is_configured = valid_languages
.iter()
.any(|valid| canonical_language(valid) == canonical);
let is_known_target = known_targets.iter().any(|known| canonical_language(known) == canonical);
if !is_configured && !is_known_target {
let mut valid_ids = valid_languages.to_vec();
for known in &known_targets {
if !valid_ids.contains(known) {
valid_ids.push(known.clone());
}
}
bail!(
"fixture '{}' ({}) has skip.languages id '{}' that is not a known e2e target \
(check for a typo); valid ids are: {}",
fixture.id,
fixture.source,
language,
valid_ids.join(", ")
);
}
}
}
Ok(())
}
pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
for f in fixtures {
groups.entry(f.resolved_category()).or_default().push(f.clone());
}
let mut result: Vec<FixtureGroup> = groups
.into_iter()
.map(|(category, fixtures)| FixtureGroup { category, fixtures })
.collect();
result.sort_by(|a, b| a.category.cmp(&b.category));
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixture_with_mock_response() {
let json = r#"{
"id": "test_chat",
"description": "Test chat",
"call": "chat",
"input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
"mock_response": {
"status": 200,
"body": {"choices": [{"message": {"content": "hello"}}]}
},
"assertions": [{"type": "not_error"}]
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(fixture.needs_mock_server());
assert!(!fixture.is_streaming_mock());
assert_eq!(fixture.mock_response.unwrap().status, 200);
}
#[test]
fn test_fixture_with_streaming_mock_response() {
let json = r#"{
"id": "test_stream",
"description": "Test streaming",
"input": {},
"mock_response": {
"status": 200,
"stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(fixture.needs_mock_server());
assert!(fixture.is_streaming_mock());
}
#[test]
fn test_fixture_without_mock_response() {
let json = r#"{
"id": "test_no_mock",
"description": "No mock",
"input": {},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(!fixture.needs_mock_server());
assert!(!fixture.is_streaming_mock());
}
#[test]
fn http_middleware_deserializes_lifecycle_hooks_keyed_by_phase() {
let json = r#"{
"lifecycle_hooks": {
"on_request": [{"name": "request_logger", "handler": "log_request"}],
"pre_validation": [
{"name": "rate_limiter", "handler": "check_rate_limit",
"config": {"max_requests": 10, "window_seconds": 60}}
]
}
}"#;
let middleware: HttpMiddleware = serde_json::from_str(json).unwrap();
let hooks = middleware
.lifecycle_hooks
.expect("lifecycle_hooks must survive deserialization");
assert_eq!(hooks["on_request"][0]["handler"], serde_json::json!("log_request"));
assert_eq!(
hooks["pre_validation"][0]["config"]["max_requests"],
serde_json::json!(10)
);
}
#[test]
fn http_middleware_deserializes_openrpc_spec_document() {
let json = r#"{
"openrpc": {
"enabled": true,
"spec": {
"openrpc": "1.3.2",
"info": {"title": "Math API", "version": "1.0.0"},
"methods": [{"name": "add"}]
}
}
}"#;
let middleware: HttpMiddleware = serde_json::from_str(json).unwrap();
let openrpc = middleware.openrpc.expect("openrpc must survive deserialization");
assert_eq!(openrpc["enabled"], serde_json::json!(true));
assert_eq!(openrpc["spec"]["methods"][0]["name"], serde_json::json!("add"));
}
#[test]
fn http_middleware_deserializes_background_tasks_config() {
let json = r#"{"background_tasks": {"enabled": true, "max_concurrent": 5}}"#;
let middleware: HttpMiddleware = serde_json::from_str(json).unwrap();
let tasks = middleware
.background_tasks
.expect("background_tasks must survive deserialization");
assert_eq!(tasks["max_concurrent"], serde_json::json!(5));
}
#[test]
fn http_middleware_deserializes_websocket_config() {
let json = r#"{"websocket": {"enabled": true}}"#;
let middleware: HttpMiddleware = serde_json::from_str(json).unwrap();
let websocket = middleware.websocket.expect("websocket must survive deserialization");
assert_eq!(websocket["enabled"], serde_json::json!(true));
}
#[test]
fn http_middleware_deserializes_authorization_policy() {
let json = r#"{"authorization": {"required_role": "admin"}}"#;
let middleware: HttpMiddleware = serde_json::from_str(json).unwrap();
let authorization = middleware
.authorization
.expect("authorization must survive deserialization");
assert_eq!(authorization["required_role"], serde_json::json!("admin"));
}
#[test]
fn http_middleware_defaults_every_field_to_none() {
let middleware: HttpMiddleware = serde_json::from_str("{}").unwrap();
assert!(middleware.lifecycle_hooks.is_none());
assert!(middleware.openrpc.is_none());
assert!(middleware.background_tasks.is_none());
assert!(middleware.websocket.is_none());
assert!(middleware.authorization.is_none());
}
#[test]
fn http_middleware_rejects_unknown_key() {
let error = serde_json::from_str::<HttpMiddleware>(r#"{"telemetry": {"enabled": true}}"#)
.expect_err("unknown middleware keys must be rejected");
assert!(
error.to_string().contains("telemetry"),
"error should name the offending key, got: {error}"
);
}
#[test]
fn has_host_root_route_true_for_origin_root_robot_route_path() {
let json = r#"{
"id": "robots_disallow_path",
"description": "Robots fixture",
"input": {
"mock_responses": [
{"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
{"path": "/", "status_code": 200, "body_inline": "<html/>"}
]
},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
}
#[test]
fn has_host_root_route_true_for_origin_root_sitemap_route_path() {
let json = r#"{
"id": "sitemap_index",
"description": "Sitemap fixture",
"input": {
"mock_responses": [
{"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
{"path": "/", "status_code": 200, "body_inline": "<html/>"}
]
},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
}
#[test]
fn has_host_root_route_true_for_origin_root_redirect_target() {
let json = r#"{
"id": "redirect_fixture",
"description": "Redirect fixture",
"input": {
"mock_responses": [
{
"path": "/",
"status_code": 302,
"headers": {"Location": "/final"},
"body_inline": ""
},
{"path": "/final", "status_code": 200, "body_inline": "{}"}
]
},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(
fixture.has_host_root_route(),
"expected origin-root listener for origin-root redirect target"
);
}
#[test]
fn has_host_root_route_true_for_origin_root_link_target() {
let json = r#"{
"id": "linked_pages",
"description": "Linked pages",
"input": {
"mock_responses": [
{
"path": "/",
"status_code": 200,
"body_inline": "<html><a href='/page'>Page</a></html>"
},
{"path": "/page", "status_code": 200, "body_inline": "{}"}
]
},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(
fixture.has_host_root_route(),
"expected origin-root listener for origin-root link target"
);
}
#[test]
fn has_host_root_route_false_for_data_json_path() {
let json = r#"{
"id": "data_endpoint",
"description": "Namespaced route fixture",
"input": {
"mock_responses": [
{"path": "/data.json", "status_code": 200, "body_inline": "{}"}
]
},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
}
#[test]
fn has_host_root_route_false_for_single_mock_response_schema() {
let json = r#"{
"id": "basic_chat",
"description": "Basic chat",
"mock_response": {"status": 200, "body": {}},
"input": {},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(
!fixture.has_host_root_route(),
"expected false for single mock_response schema"
);
}
#[test]
fn has_host_root_route_false_for_empty_mock_responses() {
let json = r#"{
"id": "empty_responses",
"description": "No mock_responses",
"input": {},
"assertions": []
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
}
#[test]
fn validate_skip_languages_accepts_known_id() {
let json = r#"{
"id": "known_skip",
"description": "Skips a real target",
"input": {},
"assertions": [],
"skip": {"languages": ["python", "node"], "reason": "not applicable"}
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
let valid = vec!["python".to_string(), "node".to_string(), "rust".to_string()];
assert!(validate_skip_languages(&[fixture], &valid).is_ok());
}
#[test]
fn validate_skip_languages_rejects_unknown_id() {
let json = r#"{
"id": "bogus_skip",
"description": "Skips a nonexistent target",
"input": {},
"assertions": [],
"skip": {"languages": ["typescript"], "reason": "wrong id"}
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
let valid = vec!["python".to_string(), "node".to_string(), "rust".to_string()];
let err = validate_skip_languages(&[fixture], &valid).expect_err("unknown id must fail validation");
let message = err.to_string();
assert!(
message.contains("bogus_skip"),
"error should name the fixture: {message}"
);
assert!(
message.contains("typescript"),
"error should name the bad id: {message}"
);
assert!(message.contains("python"), "error should list valid ids: {message}");
}
#[test]
fn validate_skip_languages_accepts_known_target_not_in_configured_list() {
let json = r#"{
"id": "held_back_skip",
"description": "Skips a target the consumer hasn't scaffolded yet",
"input": {},
"assertions": [],
"skip": {"languages": ["csharp"], "reason": "design-held backend"}
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
let valid = vec!["python".to_string(), "node".to_string(), "rust".to_string()];
assert!(
validate_skip_languages(&[fixture], &valid).is_ok(),
"a known e2e target should validate even when it isn't in the configured list"
);
}
#[test]
fn validate_skip_languages_rejects_typo_id_even_when_similar_to_known_target() {
let json = r#"{
"id": "typo_skip",
"description": "Skips a typo'd target name",
"input": {},
"assertions": [],
"skip": {"languages": ["c#", "wasm32"], "reason": "typo"}
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
let valid = vec!["python".to_string(), "node".to_string(), "rust".to_string()];
let err = validate_skip_languages(&[fixture], &valid).expect_err("typo id must still fail validation");
let message = err.to_string();
assert!(
message.contains("typo_skip"),
"error should name the fixture: {message}"
);
assert!(message.contains("c#"), "error should name the bad id: {message}");
assert!(
message.contains("not a known e2e target"),
"error should say the id is not a known e2e target: {message}"
);
}
#[test]
fn validate_skip_languages_accepts_ffi_and_c_ffi_as_aliases_of_c() {
let valid = vec!["python".to_string(), "rust".to_string()];
for alias in ["ffi", "c_ffi", "c"] {
let json = serde_json::json!({
"id": format!("{alias}_alias_skip"),
"description": "Uses one of the accepted spellings for the FFI backend",
"input": {},
"assertions": [],
"skip": {"languages": [alias], "reason": "not applicable"}
});
let fixture: Fixture = serde_json::from_value(json).unwrap();
assert!(
validate_skip_languages(&[fixture], &valid).is_ok(),
"`{alias}` names the same generator as `c` and must be accepted"
);
}
}
#[test]
fn validate_skip_languages_accepts_opt_in_only_generator_not_in_language_enum() {
let json = r#"{
"id": "brew_held_back_skip",
"description": "Skips an opt-in-only e2e target with no `Language` variant",
"input": {},
"assertions": [],
"skip": {"languages": ["brew"], "reason": "not applicable to this crate"}
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
let valid = vec!["python".to_string(), "rust".to_string()];
assert!(
validate_skip_languages(&[fixture], &valid).is_ok(),
"`brew` is a real, registered e2e generator and must validate even though \
`crate::core::config::Language` has no variant for it"
);
}
#[test]
fn validate_skip_languages_rejects_language_variant_with_no_e2e_generator() {
let json = r#"{
"id": "jni_skip",
"description": "Skips a Language variant that has no e2e generator",
"input": {},
"assertions": [],
"skip": {"languages": ["jni"], "reason": "wrong id"}
}"#;
let fixture: Fixture = serde_json::from_str(json).unwrap();
let valid = vec!["python".to_string(), "rust".to_string()];
let err = validate_skip_languages(&[fixture], &valid)
.expect_err("'jni' has no registered e2e generator and can never match a running backend");
assert!(
err.to_string().contains("not a known e2e target"),
"'jni' must be rejected: {err}"
);
}
#[test]
fn canonical_language_resolves_c_and_rust_aliases() {
assert_eq!(canonical_language("c"), "c");
assert_eq!(canonical_language("c_ffi"), "c");
assert_eq!(canonical_language("ffi"), "c");
assert_eq!(canonical_language("rust"), "rust");
assert_eq!(canonical_language("core"), "rust");
assert_eq!(canonical_language("rust_core"), "rust");
}
#[test]
fn canonical_language_passes_through_unaliased_backends() {
for language in ["wasm", "node", "kotlin_android", "php_ext", "python", "swift"] {
assert_eq!(canonical_language(language), language);
}
}
#[test]
fn skip_directive_should_skip_matches_c_ffi_alias_running_as_ffi() {
let skip = SkipDirective {
languages: vec!["c".to_string()],
reason: Some("not applicable".to_string()),
};
assert!(
skip.should_skip("ffi"),
"`skip.languages = [\"c\"]` must suppress a backend running as `ffi`"
);
assert!(
skip.should_skip("c_ffi"),
"`skip.languages = [\"c\"]` must suppress a backend running as `c_ffi`"
);
assert!(skip.should_skip("c"));
assert!(!skip.should_skip("python"));
}
#[test]
fn assertion_skip_scoped_matches_c_ffi_alias_running_as_ffi() {
let skip = AssertionSkip::Scoped(AssertionSkipDirective {
languages: vec!["c".to_string()],
kind: AssertionSkipKind::LanguageLimitation,
reason: Some("field unreachable from C".to_string()),
});
assert!(skip.should_skip("ffi"));
assert!(skip.should_skip("c_ffi"));
assert!(!skip.should_skip("python"));
}
#[test]
fn should_skip_and_canonical_language_agree_for_every_known_alias() {
for (canonical, aliases) in LANGUAGE_ALIASES {
for alias in *aliases {
let skip_by_canonical = SkipDirective {
languages: vec![(*canonical).to_string()],
reason: None,
};
assert!(
skip_by_canonical.should_skip(alias),
"declaring skip on canonical `{canonical}` must suppress alias `{alias}`"
);
let skip_by_alias = SkipDirective {
languages: vec![(*alias).to_string()],
reason: None,
};
assert!(
skip_by_alias.should_skip(canonical),
"declaring skip on alias `{alias}` must suppress canonical `{canonical}`"
);
assert_eq!(canonical_language(alias), *canonical);
}
}
}
#[test]
fn docs_files_resolve_relative_to_each_argument() {
let fixture: Fixture = serde_json::from_value(serde_json::json!({
"id": "typed_file_input",
"description": "Reads a typed document input",
"input": {"extract_input": {"kind": "bytes", "bytes": [1, 2, 3]}},
"assertions": [],
"docs": {
"topic": "guides",
"presentation": {
"files": [{"field": "/extract_input/bytes", "path": "document.pdf"}]
}
}
}))
.expect("fixture");
assert_eq!(
fixture.docs_files_for_arg("input"),
vec![FixtureDocsFileInput {
field: "/bytes".into(),
path: "document.pdf".into(),
}]
);
}
#[test]
fn assertion_expected_values_supports_plural_and_singular_forms() {
let plural: Assertion = serde_json::from_value(serde_json::json!({
"type": "not_contains",
"field": "content",
"values": ["unsafe markup", "unsafe handler"]
}))
.expect("plural assertion");
let singular: Assertion = serde_json::from_value(serde_json::json!({
"type": "not_contains",
"field": "content",
"value": "unsafe markup"
}))
.expect("singular assertion");
assert_eq!(
plural.expected_values(),
vec![
&serde_json::json!("unsafe markup"),
&serde_json::json!("unsafe handler")
]
);
assert_eq!(singular.expected_values(), vec![&serde_json::json!("unsafe markup")]);
}
}