1use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MockResponse {
11 pub status: u16,
13 #[serde(default)]
15 pub body: Option<serde_json::Value>,
16 #[serde(default)]
19 pub stream_chunks: Option<Vec<serde_json::Value>>,
20 #[serde(default)]
23 pub headers: HashMap<String, String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VisitorSpec {
29 pub callbacks: HashMap<String, CallbackAction>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "action")]
36pub enum CallbackAction {
37 #[serde(rename = "skip")]
39 Skip,
40 #[serde(rename = "continue")]
42 Continue,
43 #[serde(rename = "preserve_html")]
45 PreserveHtml,
46 #[serde(rename = "custom")]
48 Custom {
49 output: String,
51 },
52 #[serde(rename = "custom_template")]
54 CustomTemplate {
55 template: String,
57 #[serde(default)]
63 return_form: TemplateReturnForm,
64 },
65}
66
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum TemplateReturnForm {
71 #[default]
74 Dict,
75 BareString,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FixtureEnv {
82 #[serde(default)]
84 pub api_key_var: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Fixture {
90 pub id: String,
92 #[serde(default)]
94 pub category: Option<String>,
95 pub description: String,
97 #[serde(default)]
99 pub tags: Vec<String>,
100 #[serde(default)]
102 pub skip: Option<SkipDirective>,
103 #[serde(default)]
105 pub env: Option<FixtureEnv>,
106 #[serde(default)]
109 pub call: Option<String>,
110 #[serde(default)]
112 pub input: serde_json::Value,
113 #[serde(default)]
115 pub mock_response: Option<MockResponse>,
116 #[serde(default)]
118 pub visitor: Option<VisitorSpec>,
119 #[serde(default)]
121 pub assertions: Vec<Assertion>,
122 #[serde(skip)]
124 pub source: String,
125 #[serde(default)]
128 pub http: Option<HttpFixture>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct HttpFixture {
134 pub handler: HttpHandler,
136 pub request: HttpRequest,
138 pub expected_response: HttpExpectedResponse,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct HttpHandler {
145 pub route: String,
147 pub method: String,
149 #[serde(default)]
151 pub body_schema: Option<serde_json::Value>,
152 #[serde(default)]
154 pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
155 #[serde(default)]
157 pub middleware: Option<HttpMiddleware>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct HttpRequest {
163 pub method: String,
164 pub path: String,
165 #[serde(default)]
166 pub headers: HashMap<String, String>,
167 #[serde(default)]
168 pub query_params: HashMap<String, serde_json::Value>,
169 #[serde(default)]
170 pub cookies: HashMap<String, String>,
171 #[serde(default)]
172 pub body: Option<serde_json::Value>,
173 #[serde(default)]
174 pub content_type: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HttpExpectedResponse {
180 pub status_code: u16,
181 #[serde(default)]
183 pub body: Option<serde_json::Value>,
184 #[serde(default)]
186 pub body_partial: Option<serde_json::Value>,
187 #[serde(default)]
189 pub headers: HashMap<String, String>,
190 #[serde(default)]
192 pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ValidationErrorExpectation {
198 pub loc: Vec<String>,
199 pub msg: String,
200 #[serde(rename = "type")]
201 pub error_type: String,
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct CorsConfig {
207 #[serde(default)]
209 pub allow_origins: Vec<String>,
210 #[serde(default)]
212 pub allow_methods: Vec<String>,
213 #[serde(default)]
215 pub allow_headers: Vec<String>,
216 #[serde(default)]
218 pub expose_headers: Vec<String>,
219 #[serde(default)]
221 pub max_age: Option<u64>,
222 #[serde(default)]
224 pub allow_credentials: bool,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct StaticFile {
230 pub path: String,
232 pub content: String,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct StaticFilesConfig {
239 pub route_prefix: String,
241 #[serde(default)]
243 pub files: Vec<StaticFile>,
244 #[serde(default)]
246 pub index_file: bool,
247 #[serde(default)]
249 pub cache_control: Option<String>,
250}
251
252#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct HttpMiddleware {
255 #[serde(default)]
256 pub jwt_auth: Option<serde_json::Value>,
257 #[serde(default)]
258 pub api_key_auth: Option<serde_json::Value>,
259 #[serde(default)]
260 pub compression: Option<serde_json::Value>,
261 #[serde(default)]
262 pub rate_limit: Option<serde_json::Value>,
263 #[serde(default)]
264 pub request_timeout: Option<serde_json::Value>,
265 #[serde(default)]
266 pub request_id: Option<serde_json::Value>,
267 #[serde(default)]
269 pub cors: Option<CorsConfig>,
270 #[serde(default)]
272 pub static_files: Option<Vec<StaticFilesConfig>>,
273}
274
275fn is_host_root_path(path: &str) -> bool {
279 path.starts_with("/robots") || path.starts_with("/sitemap")
280}
281
282impl Fixture {
283 pub fn is_http_test(&self) -> bool {
285 self.http.is_some()
286 }
287
288 pub fn needs_mock_server(&self) -> bool {
293 if self.mock_response.is_some() || self.http.is_some() {
294 return true;
295 }
296 self.input
298 .get("mock_responses")
299 .and_then(|v| v.as_array())
300 .map(|arr| !arr.is_empty())
301 .unwrap_or(false)
302 }
303
304 pub fn as_mock_response(&self) -> Option<MockResponse> {
310 if let Some(mock) = &self.mock_response {
311 return Some(mock.clone());
312 }
313 if let Some(http) = &self.http {
314 return Some(MockResponse {
315 status: http.expected_response.status_code,
316 body: http.expected_response.body.clone(),
317 stream_chunks: None,
318 headers: http.expected_response.headers.clone(),
319 });
320 }
321 None
322 }
323
324 pub fn is_streaming_mock(&self) -> bool {
326 self.mock_response
327 .as_ref()
328 .and_then(|m| m.stream_chunks.as_ref())
329 .map(|c| !c.is_empty())
330 .unwrap_or(false)
331 }
332
333 pub fn has_host_root_route(&self) -> bool {
341 if let Some(arr) = self.input.get("mock_responses").and_then(|v| v.as_array()) {
343 if arr.iter().any(|entry| {
345 entry
346 .get("path")
347 .and_then(|v| v.as_str())
348 .map(is_host_root_path)
349 .unwrap_or(false)
350 }) {
351 return true;
352 }
353 return arr.iter().any(|entry| {
361 let status = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(0);
362 let headers = entry.get("headers").and_then(|v| v.as_object());
363 let location_redirect = (300..400).contains(&status)
364 && headers
365 .map(|hdrs| {
366 hdrs.iter().any(|(name, value)| {
367 name.eq_ignore_ascii_case("location")
368 && value.as_str().is_some_and(|s| s.starts_with('/'))
369 })
370 })
371 .unwrap_or(false);
372 let refresh_redirect = headers
373 .map(|hdrs| {
374 hdrs.iter().any(|(name, value)| {
375 if !name.eq_ignore_ascii_case("refresh") {
376 return false;
377 }
378 value
379 .as_str()
380 .and_then(|s| s.to_ascii_lowercase().find("url=").map(|i| (s.to_owned(), i)))
381 .map(|(s, idx)| s[idx + 4..].trim_start().starts_with('/'))
382 .unwrap_or(false)
383 })
384 })
385 .unwrap_or(false);
386 let meta_refresh = entry
387 .get("body_inline")
388 .and_then(|v| v.as_str())
389 .map(|body| {
390 let lower = body.to_ascii_lowercase();
391 lower
392 .split("http-equiv=\"refresh\"")
393 .nth(1)
394 .and_then(|s| s.split("content=").nth(1))
395 .map(|s| s.trim_start_matches(['"', '\'']).contains("url=/"))
396 .unwrap_or(false)
397 })
398 .unwrap_or(false);
399 location_redirect || refresh_redirect || meta_refresh
400 });
401 }
402 false
403 }
404
405 pub fn resolved_category(&self) -> String {
407 self.category.clone().unwrap_or_else(|| {
408 Path::new(&self.source)
409 .parent()
410 .and_then(|p| p.file_name())
411 .and_then(|n| n.to_str())
412 .unwrap_or("default")
413 .to_string()
414 })
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize)]
420pub struct SkipDirective {
421 #[serde(default)]
423 pub languages: Vec<String>,
424 #[serde(default)]
426 pub reason: Option<String>,
427}
428
429impl SkipDirective {
430 pub fn should_skip(&self, language: &str) -> bool {
432 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
433 }
434}
435
436#[derive(Debug, Clone, Default, Serialize, Deserialize)]
438pub struct Assertion {
439 #[serde(rename = "type")]
441 pub assertion_type: String,
442 #[serde(default)]
444 pub field: Option<String>,
445 #[serde(default)]
447 pub value: Option<serde_json::Value>,
448 #[serde(default)]
450 pub values: Option<Vec<serde_json::Value>>,
451 #[serde(default)]
453 pub method: Option<String>,
454 #[serde(default)]
456 pub check: Option<String>,
457 #[serde(default)]
459 pub args: Option<serde_json::Value>,
460 #[serde(default)]
469 pub return_type: Option<String>,
470}
471
472#[derive(Debug, Clone)]
474pub struct FixtureGroup {
475 pub category: String,
476 pub fixtures: Vec<Fixture>,
477}
478
479pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
481 let mut fixtures = Vec::new();
482 load_fixtures_recursive(dir, dir, &mut fixtures)?;
483
484 let mut seen: HashMap<String, String> = HashMap::new();
486 for f in &fixtures {
487 if let Some(prev_source) = seen.get(&f.id) {
488 bail!(
489 "duplicate fixture ID '{}': found in '{}' and '{}'",
490 f.id,
491 prev_source,
492 f.source
493 );
494 }
495 seen.insert(f.id.clone(), f.source.clone());
496 }
497
498 fixtures.sort_by(|a, b| {
500 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
501 cat_cmp.then_with(|| a.id.cmp(&b.id))
502 });
503
504 Ok(fixtures)
505}
506
507fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
508 let entries =
509 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
510
511 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
512 paths.sort();
513
514 for path in paths {
515 if path.is_dir() {
516 load_fixtures_recursive(base, &path, fixtures)?;
517 } else if path.extension().is_some_and(|ext| ext == "json") {
518 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
519 if filename == "schema.json" || filename.starts_with('_') {
521 continue;
522 }
523 let content = std::fs::read_to_string(&path)
524 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
525 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
526
527 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
529 serde_json::from_str(&content)
530 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
531 } else {
532 let single: Fixture = serde_json::from_str(&content)
533 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
534 vec![single]
535 };
536
537 for mut fixture in parsed {
538 fixture.source = relative.clone();
539 expand_json_templates(&mut fixture.input);
542 if let Some(ref mut http) = fixture.http {
543 for (_, v) in http.request.headers.iter_mut() {
544 *v = crate::escape::expand_fixture_templates(v);
545 }
546 if let Some(ref mut body) = http.request.body {
547 expand_json_templates(body);
548 }
549 }
550 normalize_assertions(&mut fixture);
551 fixtures.push(fixture);
552 }
553 }
554 }
555 Ok(())
556}
557
558pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
560 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
561 for f in fixtures {
562 groups.entry(f.resolved_category()).or_default().push(f.clone());
563 }
564 let mut result: Vec<FixtureGroup> = groups
565 .into_iter()
566 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
567 .collect();
568 result.sort_by(|a, b| a.category.cmp(&b.category));
569 result
570}
571
572fn normalize_assertions(fixture: &mut Fixture) {
590 for assertion in fixture.assertions.iter_mut() {
591 let Some(field) = assertion.field.as_deref() else {
592 continue;
593 };
594 let bare = field.strip_prefix("crawl.").unwrap_or(field);
597 match bare {
598 "pages_crawled" => {
599 assertion.field = Some("pages".to_string());
600 if assertion.assertion_type == "equals" {
601 assertion.assertion_type = "count_equals".to_string();
602 }
603 }
604 "min_pages" => {
605 assertion.field = Some("pages".to_string());
606 if assertion.assertion_type == "greater_than_or_equal" {
607 assertion.assertion_type = "count_min".to_string();
608 }
609 }
610 _ => {}
611 }
612 }
613}
614
615fn expand_json_templates(value: &mut serde_json::Value) {
617 match value {
618 serde_json::Value::String(s) => {
619 let expanded = crate::escape::expand_fixture_templates(s);
620 if expanded != *s {
621 *s = expanded;
622 }
623 }
624 serde_json::Value::Array(arr) => {
625 for item in arr {
626 expand_json_templates(item);
627 }
628 }
629 serde_json::Value::Object(map) => {
630 for (_, v) in map.iter_mut() {
631 expand_json_templates(v);
632 }
633 }
634 _ => {}
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn test_fixture_with_mock_response() {
644 let json = r#"{
645 "id": "test_chat",
646 "description": "Test chat",
647 "call": "chat",
648 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
649 "mock_response": {
650 "status": 200,
651 "body": {"choices": [{"message": {"content": "hello"}}]}
652 },
653 "assertions": [{"type": "not_error"}]
654 }"#;
655 let fixture: Fixture = serde_json::from_str(json).unwrap();
656 assert!(fixture.needs_mock_server());
657 assert!(!fixture.is_streaming_mock());
658 assert_eq!(fixture.mock_response.unwrap().status, 200);
659 }
660
661 #[test]
662 fn test_fixture_with_streaming_mock_response() {
663 let json = r#"{
664 "id": "test_stream",
665 "description": "Test streaming",
666 "input": {},
667 "mock_response": {
668 "status": 200,
669 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
670 },
671 "assertions": []
672 }"#;
673 let fixture: Fixture = serde_json::from_str(json).unwrap();
674 assert!(fixture.needs_mock_server());
675 assert!(fixture.is_streaming_mock());
676 }
677
678 fn make_fixture_with_assertion(assertion_json: &str) -> Fixture {
679 let json = format!(
680 r#"{{
681 "id": "x",
682 "description": "x",
683 "input": {{}},
684 "assertions": [{assertion_json}]
685 }}"#,
686 );
687 serde_json::from_str(&json).unwrap()
688 }
689
690 #[test]
691 fn normalize_assertions_rewrites_pages_crawled_to_count_equals_on_pages() {
692 let mut fixture =
693 make_fixture_with_assertion(r#"{"type": "equals", "field": "crawl.pages_crawled", "value": 3}"#);
694 normalize_assertions(&mut fixture);
695 assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
696 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
697 }
698
699 #[test]
700 fn normalize_assertions_rewrites_bare_pages_crawled() {
701 let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "pages_crawled", "value": 5}"#);
702 normalize_assertions(&mut fixture);
703 assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
704 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
705 }
706
707 #[test]
708 fn normalize_assertions_rewrites_min_pages_to_count_min_on_pages() {
709 let mut fixture =
710 make_fixture_with_assertion(r#"{"type": "greater_than_or_equal", "field": "crawl.min_pages", "value": 2}"#);
711 normalize_assertions(&mut fixture);
712 assert_eq!(fixture.assertions[0].assertion_type, "count_min");
713 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
714 }
715
716 #[test]
717 fn normalize_assertions_leaves_unrelated_fields_unchanged() {
718 let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "content", "value": "hi"}"#);
719 normalize_assertions(&mut fixture);
720 assert_eq!(fixture.assertions[0].assertion_type, "equals");
721 assert_eq!(fixture.assertions[0].field.as_deref(), Some("content"));
722 }
723
724 #[test]
725 fn test_fixture_without_mock_response() {
726 let json = r#"{
727 "id": "test_no_mock",
728 "description": "No mock",
729 "input": {},
730 "assertions": []
731 }"#;
732 let fixture: Fixture = serde_json::from_str(json).unwrap();
733 assert!(!fixture.needs_mock_server());
734 assert!(!fixture.is_streaming_mock());
735 }
736
737 #[test]
738 fn has_host_root_route_true_for_robots_path() {
739 let json = r#"{
740 "id": "robots_disallow_path",
741 "description": "Robots fixture",
742 "input": {
743 "mock_responses": [
744 {"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
745 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
746 ]
747 },
748 "assertions": []
749 }"#;
750 let fixture: Fixture = serde_json::from_str(json).unwrap();
751 assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
752 }
753
754 #[test]
755 fn has_host_root_route_true_for_sitemap_path() {
756 let json = r#"{
757 "id": "sitemap_index",
758 "description": "Sitemap fixture",
759 "input": {
760 "mock_responses": [
761 {"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
762 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
763 ]
764 },
765 "assertions": []
766 }"#;
767 let fixture: Fixture = serde_json::from_str(json).unwrap();
768 assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
769 }
770
771 #[test]
772 fn has_host_root_route_false_for_data_json_path() {
773 let json = r#"{
774 "id": "data_endpoint",
775 "description": "Non-host-root fixture",
776 "input": {
777 "mock_responses": [
778 {"path": "/data.json", "status_code": 200, "body_inline": "{}"}
779 ]
780 },
781 "assertions": []
782 }"#;
783 let fixture: Fixture = serde_json::from_str(json).unwrap();
784 assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
785 }
786
787 #[test]
788 fn has_host_root_route_false_for_single_mock_response_schema() {
789 let json = r#"{
791 "id": "basic_chat",
792 "description": "Basic chat",
793 "mock_response": {"status": 200, "body": {}},
794 "input": {},
795 "assertions": []
796 }"#;
797 let fixture: Fixture = serde_json::from_str(json).unwrap();
798 assert!(
799 !fixture.has_host_root_route(),
800 "expected false for single mock_response schema"
801 );
802 }
803
804 #[test]
805 fn has_host_root_route_false_for_empty_mock_responses() {
806 let json = r#"{
807 "id": "empty_responses",
808 "description": "No mock_responses",
809 "input": {},
810 "assertions": []
811 }"#;
812 let fixture: Fixture = serde_json::from_str(json).unwrap();
813 assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
814 }
815}