Skip to main content

alef_e2e/
fixture.rs

1//! Fixture loading, validation, and grouping for e2e test generation.
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8/// Mock HTTP response for testing HTTP clients.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MockResponse {
11    /// HTTP status code.
12    pub status: u16,
13    /// JSON response body (for non-streaming responses).
14    #[serde(default)]
15    pub body: Option<serde_json::Value>,
16    /// SSE stream chunks (for streaming responses).
17    /// Each chunk is a JSON object sent as `data: <chunk>\n\n`.
18    #[serde(default)]
19    pub stream_chunks: Option<Vec<serde_json::Value>>,
20    /// Response headers to apply to the mock response.
21    /// Bridged from `http.expected_response.headers` for spikard-style fixtures.
22    #[serde(default)]
23    pub headers: HashMap<String, String>,
24}
25
26/// Visitor specification for visitor pattern tests.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VisitorSpec {
29    /// Map of callback method name to action.
30    pub callbacks: HashMap<String, CallbackAction>,
31}
32
33/// Action a visitor callback should take.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "action")]
36pub enum CallbackAction {
37    /// Return VisitResult::Skip.
38    #[serde(rename = "skip")]
39    Skip,
40    /// Return VisitResult::Continue.
41    #[serde(rename = "continue")]
42    Continue,
43    /// Return VisitResult::PreserveHtml.
44    #[serde(rename = "preserve_html")]
45    PreserveHtml,
46    /// Return VisitResult::Custom with static output.
47    #[serde(rename = "custom")]
48    Custom {
49        /// The static replacement string.
50        output: String,
51    },
52    /// Return VisitResult::Custom with template interpolation.
53    #[serde(rename = "custom_template")]
54    CustomTemplate {
55        /// Template with placeholders like {text}, {href}.
56        template: String,
57    },
58}
59
60/// A single e2e test fixture.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Fixture {
63    /// Unique identifier (used as test function name).
64    pub id: String,
65    /// Optional category (defaults to parent directory name).
66    #[serde(default)]
67    pub category: Option<String>,
68    /// Human-readable description.
69    pub description: String,
70    /// Optional tags for filtering.
71    #[serde(default)]
72    pub tags: Vec<String>,
73    /// Skip directive.
74    #[serde(default)]
75    pub skip: Option<SkipDirective>,
76    /// Named call config to use (references `[e2e.calls.<name>]`).
77    /// When omitted, uses the default `[e2e.call]`.
78    #[serde(default)]
79    pub call: Option<String>,
80    /// Input data passed to the function under test.
81    #[serde(default)]
82    pub input: serde_json::Value,
83    /// Optional mock HTTP response for testing HTTP clients.
84    #[serde(default)]
85    pub mock_response: Option<MockResponse>,
86    /// Optional visitor specification for visitor pattern tests.
87    #[serde(default)]
88    pub visitor: Option<VisitorSpec>,
89    /// List of assertions to check.
90    #[serde(default)]
91    pub assertions: Vec<Assertion>,
92    /// Source file path (populated during loading).
93    #[serde(skip)]
94    pub source: String,
95    /// HTTP server test specification. When present, this fixture tests
96    /// an HTTP handler rather than a function call.
97    #[serde(default)]
98    pub http: Option<HttpFixture>,
99}
100
101/// HTTP server test specification.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct HttpFixture {
104    /// Handler/route definition.
105    pub handler: HttpHandler,
106    /// The HTTP request to send.
107    pub request: HttpRequest,
108    /// Expected response.
109    pub expected_response: HttpExpectedResponse,
110}
111
112/// Handler/route definition for HTTP server tests.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct HttpHandler {
115    /// Route pattern (e.g., "/users/{user_id}").
116    pub route: String,
117    /// HTTP method (GET, POST, PUT, etc.).
118    pub method: String,
119    /// JSON Schema for request body validation.
120    #[serde(default)]
121    pub body_schema: Option<serde_json::Value>,
122    /// Parameter schemas by source (path, query, header, cookie).
123    #[serde(default)]
124    pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
125    /// Middleware configuration.
126    #[serde(default)]
127    pub middleware: Option<HttpMiddleware>,
128}
129
130/// HTTP request to send in a server test.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct HttpRequest {
133    pub method: String,
134    pub path: String,
135    #[serde(default)]
136    pub headers: HashMap<String, String>,
137    #[serde(default)]
138    pub query_params: HashMap<String, serde_json::Value>,
139    #[serde(default)]
140    pub cookies: HashMap<String, String>,
141    #[serde(default)]
142    pub body: Option<serde_json::Value>,
143    #[serde(default)]
144    pub content_type: Option<String>,
145}
146
147/// Expected HTTP response specification.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct HttpExpectedResponse {
150    pub status_code: u16,
151    /// Exact body match.
152    #[serde(default)]
153    pub body: Option<serde_json::Value>,
154    /// Partial body match (only check specified fields).
155    #[serde(default)]
156    pub body_partial: Option<serde_json::Value>,
157    /// Header expectations. Special tokens: `<<uuid>>`, `<<present>>`, `<<absent>>`.
158    #[serde(default)]
159    pub headers: HashMap<String, String>,
160    /// Expected validation errors (for 422 responses).
161    #[serde(default)]
162    pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
163}
164
165/// Expected validation error entry.
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct ValidationErrorExpectation {
168    pub loc: Vec<String>,
169    pub msg: String,
170    #[serde(rename = "type")]
171    pub error_type: String,
172}
173
174/// CORS policy configuration for HTTP handler tests.
175#[derive(Debug, Clone, Default, Serialize, Deserialize)]
176pub struct CorsConfig {
177    /// Allowed origins (e.g. `["https://example.com"]`). Empty means deny all.
178    #[serde(default)]
179    pub allow_origins: Vec<String>,
180    /// Allowed HTTP methods (e.g. `["GET", "POST"]`). Empty means deny all.
181    #[serde(default)]
182    pub allow_methods: Vec<String>,
183    /// Allowed request headers (e.g. `["Content-Type"]`). Empty means deny all.
184    #[serde(default)]
185    pub allow_headers: Vec<String>,
186    /// Exposed response headers (e.g. `["X-Total-Count"]`).
187    #[serde(default)]
188    pub expose_headers: Vec<String>,
189    /// `Access-Control-Max-Age` value in seconds.
190    #[serde(default)]
191    pub max_age: Option<u64>,
192    /// Whether to allow credentials.
193    #[serde(default)]
194    pub allow_credentials: bool,
195}
196
197/// A single static file entry for the static-files middleware.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct StaticFile {
200    /// Relative path within the served directory (e.g. `"hello.txt"`).
201    pub path: String,
202    /// File content (plain text or HTML string).
203    pub content: String,
204}
205
206/// Static-files middleware configuration for HTTP handler tests.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct StaticFilesConfig {
209    /// URL route prefix (e.g. `"/public"`).
210    pub route_prefix: String,
211    /// Files to write to the temporary directory.
212    #[serde(default)]
213    pub files: Vec<StaticFile>,
214    /// Whether to serve `index.html` for directory requests.
215    #[serde(default)]
216    pub index_file: bool,
217    /// `Cache-Control` header value to apply.
218    #[serde(default)]
219    pub cache_control: Option<String>,
220}
221
222/// Middleware configuration for HTTP handler tests.
223#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224pub struct HttpMiddleware {
225    #[serde(default)]
226    pub jwt_auth: Option<serde_json::Value>,
227    #[serde(default)]
228    pub api_key_auth: Option<serde_json::Value>,
229    #[serde(default)]
230    pub compression: Option<serde_json::Value>,
231    #[serde(default)]
232    pub rate_limit: Option<serde_json::Value>,
233    #[serde(default)]
234    pub request_timeout: Option<serde_json::Value>,
235    #[serde(default)]
236    pub request_id: Option<serde_json::Value>,
237    /// CORS policy to apply via tower-http `CorsLayer`.
238    #[serde(default)]
239    pub cors: Option<CorsConfig>,
240    /// Static-files configuration to serve via tower-http `ServeDir`.
241    #[serde(default)]
242    pub static_files: Option<Vec<StaticFilesConfig>>,
243}
244
245impl Fixture {
246    /// Returns true if this is an HTTP server test fixture.
247    pub fn is_http_test(&self) -> bool {
248        self.http.is_some()
249    }
250
251    /// Returns true if this fixture requires a mock HTTP server.
252    /// This is true when either `mock_response` (liter-llm shape) or
253    /// `http.expected_response` (spikard shape) is present.
254    pub fn needs_mock_server(&self) -> bool {
255        self.mock_response.is_some() || self.http.is_some()
256    }
257
258    /// Returns the effective mock response for this fixture, bridging both schemas:
259    /// - liter-llm shape: `mock_response: { status, body, stream_chunks }`
260    /// - spikard shape: `http.expected_response: { status_code, body, headers }`
261    ///
262    /// Returns `None` if neither schema is present.
263    pub fn as_mock_response(&self) -> Option<MockResponse> {
264        if let Some(mock) = &self.mock_response {
265            return Some(mock.clone());
266        }
267        if let Some(http) = &self.http {
268            return Some(MockResponse {
269                status: http.expected_response.status_code,
270                body: http.expected_response.body.clone(),
271                stream_chunks: None,
272                headers: http.expected_response.headers.clone(),
273            });
274        }
275        None
276    }
277
278    /// Returns true if the mock response uses streaming (SSE).
279    pub fn is_streaming_mock(&self) -> bool {
280        self.mock_response
281            .as_ref()
282            .and_then(|m| m.stream_chunks.as_ref())
283            .map(|c| !c.is_empty())
284            .unwrap_or(false)
285    }
286
287    /// Get the resolved category (explicit or from source directory).
288    pub fn resolved_category(&self) -> String {
289        self.category.clone().unwrap_or_else(|| {
290            Path::new(&self.source)
291                .parent()
292                .and_then(|p| p.file_name())
293                .and_then(|n| n.to_str())
294                .unwrap_or("default")
295                .to_string()
296        })
297    }
298}
299
300/// Skip directive for conditionally excluding fixtures.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct SkipDirective {
303    /// Languages to skip (empty means skip all).
304    #[serde(default)]
305    pub languages: Vec<String>,
306    /// Human-readable reason for skipping.
307    #[serde(default)]
308    pub reason: Option<String>,
309}
310
311impl SkipDirective {
312    /// Check if this fixture should be skipped for a given language.
313    pub fn should_skip(&self, language: &str) -> bool {
314        self.languages.is_empty() || self.languages.iter().any(|l| l == language)
315    }
316}
317
318/// A single assertion in a fixture.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct Assertion {
321    /// Assertion type (equals, contains, not_empty, error, etc.).
322    #[serde(rename = "type")]
323    pub assertion_type: String,
324    /// Field path to access on the result (dot-separated).
325    #[serde(default)]
326    pub field: Option<String>,
327    /// Expected value (string, number, bool, or array depending on type).
328    #[serde(default)]
329    pub value: Option<serde_json::Value>,
330    /// Expected values (for contains_all, contains_any).
331    #[serde(default)]
332    pub values: Option<Vec<serde_json::Value>>,
333    /// Method name to call on the result (for method_result assertions).
334    #[serde(default)]
335    pub method: Option<String>,
336    /// Assertion check type for the method result (equals, is_true, is_false, greater_than_or_equal, count_min).
337    #[serde(default)]
338    pub check: Option<String>,
339    /// Arguments to pass to the method call (for method_result assertions).
340    #[serde(default)]
341    pub args: Option<serde_json::Value>,
342}
343
344/// A group of fixtures sharing the same category.
345#[derive(Debug, Clone)]
346pub struct FixtureGroup {
347    pub category: String,
348    pub fixtures: Vec<Fixture>,
349}
350
351/// Load all fixtures from a directory recursively.
352pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
353    let mut fixtures = Vec::new();
354    load_fixtures_recursive(dir, dir, &mut fixtures)?;
355
356    // Validate: check for duplicate IDs
357    let mut seen: HashMap<String, String> = HashMap::new();
358    for f in &fixtures {
359        if let Some(prev_source) = seen.get(&f.id) {
360            bail!(
361                "duplicate fixture ID '{}': found in '{}' and '{}'",
362                f.id,
363                prev_source,
364                f.source
365            );
366        }
367        seen.insert(f.id.clone(), f.source.clone());
368    }
369
370    // Sort by (category, id) for deterministic output
371    fixtures.sort_by(|a, b| {
372        let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
373        cat_cmp.then_with(|| a.id.cmp(&b.id))
374    });
375
376    Ok(fixtures)
377}
378
379fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
380    let entries =
381        std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
382
383    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
384    paths.sort();
385
386    for path in paths {
387        if path.is_dir() {
388            load_fixtures_recursive(base, &path, fixtures)?;
389        } else if path.extension().is_some_and(|ext| ext == "json") {
390            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
391            // Skip schema files and files starting with _
392            if filename == "schema.json" || filename.starts_with('_') {
393                continue;
394            }
395            let content = std::fs::read_to_string(&path)
396                .with_context(|| format!("failed to read fixture: {}", path.display()))?;
397            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
398
399            // Try parsing as array first, then as single fixture
400            let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
401                serde_json::from_str(&content)
402                    .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
403            } else {
404                let single: Fixture = serde_json::from_str(&content)
405                    .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
406                vec![single]
407            };
408
409            for mut fixture in parsed {
410                fixture.source = relative.clone();
411                // Expand template expressions (e.g. `{{ repeat 'x' 10000 times }}`)
412                // in all JSON string values so generators emit the expanded values.
413                expand_json_templates(&mut fixture.input);
414                if let Some(ref mut http) = fixture.http {
415                    for (_, v) in http.request.headers.iter_mut() {
416                        *v = crate::escape::expand_fixture_templates(v);
417                    }
418                    if let Some(ref mut body) = http.request.body {
419                        expand_json_templates(body);
420                    }
421                }
422                fixtures.push(fixture);
423            }
424        }
425    }
426    Ok(())
427}
428
429/// Group fixtures by their resolved category.
430pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
431    let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
432    for f in fixtures {
433        groups.entry(f.resolved_category()).or_default().push(f.clone());
434    }
435    let mut result: Vec<FixtureGroup> = groups
436        .into_iter()
437        .map(|(category, fixtures)| FixtureGroup { category, fixtures })
438        .collect();
439    result.sort_by(|a, b| a.category.cmp(&b.category));
440    result
441}
442
443/// Recursively expand fixture template expressions in all string values of a JSON tree.
444fn expand_json_templates(value: &mut serde_json::Value) {
445    match value {
446        serde_json::Value::String(s) => {
447            let expanded = crate::escape::expand_fixture_templates(s);
448            if expanded != *s {
449                *s = expanded;
450            }
451        }
452        serde_json::Value::Array(arr) => {
453            for item in arr {
454                expand_json_templates(item);
455            }
456        }
457        serde_json::Value::Object(map) => {
458            for (_, v) in map.iter_mut() {
459                expand_json_templates(v);
460            }
461        }
462        _ => {}
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    #[test]
471    fn test_fixture_with_mock_response() {
472        let json = r#"{
473            "id": "test_chat",
474            "description": "Test chat",
475            "call": "chat",
476            "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
477            "mock_response": {
478                "status": 200,
479                "body": {"choices": [{"message": {"content": "hello"}}]}
480            },
481            "assertions": [{"type": "not_error"}]
482        }"#;
483        let fixture: Fixture = serde_json::from_str(json).unwrap();
484        assert!(fixture.needs_mock_server());
485        assert!(!fixture.is_streaming_mock());
486        assert_eq!(fixture.mock_response.unwrap().status, 200);
487    }
488
489    #[test]
490    fn test_fixture_with_streaming_mock_response() {
491        let json = r#"{
492            "id": "test_stream",
493            "description": "Test streaming",
494            "input": {},
495            "mock_response": {
496                "status": 200,
497                "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
498            },
499            "assertions": []
500        }"#;
501        let fixture: Fixture = serde_json::from_str(json).unwrap();
502        assert!(fixture.needs_mock_server());
503        assert!(fixture.is_streaming_mock());
504    }
505
506    #[test]
507    fn test_fixture_without_mock_response() {
508        let json = r#"{
509            "id": "test_no_mock",
510            "description": "No mock",
511            "input": {},
512            "assertions": []
513        }"#;
514        let fixture: Fixture = serde_json::from_str(json).unwrap();
515        assert!(!fixture.needs_mock_server());
516        assert!(!fixture.is_streaming_mock());
517    }
518}