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 let inline_host_link = entry
406 .get("body_inline")
407 .and_then(|v| v.as_str())
408 .map(|body| body.contains("href=\"/") || body.contains("href='/"))
409 .unwrap_or(false);
410 location_redirect || refresh_redirect || meta_refresh || inline_host_link
411 });
412 }
413 false
414 }
415
416 pub fn resolved_category(&self) -> String {
418 self.category.clone().unwrap_or_else(|| {
419 Path::new(&self.source)
420 .parent()
421 .and_then(|p| p.file_name())
422 .and_then(|n| n.to_str())
423 .unwrap_or("default")
424 .to_string()
425 })
426 }
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct SkipDirective {
432 #[serde(default)]
434 pub languages: Vec<String>,
435 #[serde(default)]
437 pub reason: Option<String>,
438}
439
440impl SkipDirective {
441 pub fn should_skip(&self, language: &str) -> bool {
443 self.languages.is_empty() || self.languages.iter().any(|l| l == language)
444 }
445}
446
447#[derive(Debug, Clone, Default, Serialize, Deserialize)]
449pub struct Assertion {
450 #[serde(rename = "type")]
452 pub assertion_type: String,
453 #[serde(default)]
455 pub field: Option<String>,
456 #[serde(default)]
458 pub value: Option<serde_json::Value>,
459 #[serde(default)]
461 pub values: Option<Vec<serde_json::Value>>,
462 #[serde(default)]
464 pub method: Option<String>,
465 #[serde(default)]
467 pub check: Option<String>,
468 #[serde(default)]
470 pub args: Option<serde_json::Value>,
471 #[serde(default)]
480 pub return_type: Option<String>,
481}
482
483#[derive(Debug, Clone)]
485pub struct FixtureGroup {
486 pub category: String,
487 pub fixtures: Vec<Fixture>,
488}
489
490pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
492 let mut fixtures = Vec::new();
493 load_fixtures_recursive(dir, dir, &mut fixtures)?;
494
495 let mut seen: HashMap<String, String> = HashMap::new();
497 for f in &fixtures {
498 if let Some(prev_source) = seen.get(&f.id) {
499 bail!(
500 "duplicate fixture ID '{}': found in '{}' and '{}'",
501 f.id,
502 prev_source,
503 f.source
504 );
505 }
506 seen.insert(f.id.clone(), f.source.clone());
507 }
508
509 fixtures.sort_by(|a, b| {
511 let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
512 cat_cmp.then_with(|| a.id.cmp(&b.id))
513 });
514
515 Ok(fixtures)
516}
517
518fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
519 let entries =
520 std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
521
522 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
523 paths.sort();
524
525 for path in paths {
526 if path.is_dir() {
527 load_fixtures_recursive(base, &path, fixtures)?;
528 } else if path.extension().is_some_and(|ext| ext == "json") {
529 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
530 if filename == "schema.json" || filename.starts_with('_') {
532 continue;
533 }
534 let content = std::fs::read_to_string(&path)
535 .with_context(|| format!("failed to read fixture: {}", path.display()))?;
536 let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
537
538 let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
540 serde_json::from_str(&content)
541 .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
542 } else {
543 let single: Fixture = serde_json::from_str(&content)
544 .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
545 vec![single]
546 };
547
548 for mut fixture in parsed {
549 fixture.source = relative.clone();
550 expand_json_templates(&mut fixture.input);
553 if let Some(ref mut http) = fixture.http {
554 for (_, v) in http.request.headers.iter_mut() {
555 *v = crate::escape::expand_fixture_templates(v);
556 }
557 if let Some(ref mut body) = http.request.body {
558 expand_json_templates(body);
559 }
560 }
561 normalize_assertions(&mut fixture);
562 fixtures.push(fixture);
563 }
564 }
565 }
566 Ok(())
567}
568
569pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
571 let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
572 for f in fixtures {
573 groups.entry(f.resolved_category()).or_default().push(f.clone());
574 }
575 let mut result: Vec<FixtureGroup> = groups
576 .into_iter()
577 .map(|(category, fixtures)| FixtureGroup { category, fixtures })
578 .collect();
579 result.sort_by(|a, b| a.category.cmp(&b.category));
580 result
581}
582
583fn normalize_assertions(fixture: &mut Fixture) {
601 for assertion in fixture.assertions.iter_mut() {
602 let Some(field) = assertion.field.as_deref() else {
603 continue;
604 };
605 let bare = field.strip_prefix("crawl.").unwrap_or(field);
608 match bare {
609 "pages_crawled" => {
610 assertion.field = Some("pages".to_string());
611 match assertion.assertion_type.as_str() {
612 "equals" => assertion.assertion_type = "count_equals".to_string(),
613 "greater_than_or_equal" => assertion.assertion_type = "count_min".to_string(),
614 "less_than_or_equal" => assertion.assertion_type = "count_max".to_string(),
615 _ => {}
616 }
617 }
618 "min_pages" => {
619 assertion.field = Some("pages".to_string());
620 if assertion.assertion_type == "greater_than_or_equal" {
621 assertion.assertion_type = "count_min".to_string();
622 }
623 }
624 _ => {}
625 }
626 }
627}
628
629fn expand_json_templates(value: &mut serde_json::Value) {
631 match value {
632 serde_json::Value::String(s) => {
633 let expanded = crate::escape::expand_fixture_templates(s);
634 if expanded != *s {
635 *s = expanded;
636 }
637 }
638 serde_json::Value::Array(arr) => {
639 for item in arr {
640 expand_json_templates(item);
641 }
642 }
643 serde_json::Value::Object(map) => {
644 for (_, v) in map.iter_mut() {
645 expand_json_templates(v);
646 }
647 }
648 _ => {}
649 }
650}
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[test]
657 fn test_fixture_with_mock_response() {
658 let json = r#"{
659 "id": "test_chat",
660 "description": "Test chat",
661 "call": "chat",
662 "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
663 "mock_response": {
664 "status": 200,
665 "body": {"choices": [{"message": {"content": "hello"}}]}
666 },
667 "assertions": [{"type": "not_error"}]
668 }"#;
669 let fixture: Fixture = serde_json::from_str(json).unwrap();
670 assert!(fixture.needs_mock_server());
671 assert!(!fixture.is_streaming_mock());
672 assert_eq!(fixture.mock_response.unwrap().status, 200);
673 }
674
675 #[test]
676 fn test_fixture_with_streaming_mock_response() {
677 let json = r#"{
678 "id": "test_stream",
679 "description": "Test streaming",
680 "input": {},
681 "mock_response": {
682 "status": 200,
683 "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
684 },
685 "assertions": []
686 }"#;
687 let fixture: Fixture = serde_json::from_str(json).unwrap();
688 assert!(fixture.needs_mock_server());
689 assert!(fixture.is_streaming_mock());
690 }
691
692 fn make_fixture_with_assertion(assertion_json: &str) -> Fixture {
693 let json = format!(
694 r#"{{
695 "id": "x",
696 "description": "x",
697 "input": {{}},
698 "assertions": [{assertion_json}]
699 }}"#,
700 );
701 serde_json::from_str(&json).unwrap()
702 }
703
704 #[test]
705 fn normalize_assertions_rewrites_pages_crawled_to_count_equals_on_pages() {
706 let mut fixture =
707 make_fixture_with_assertion(r#"{"type": "equals", "field": "crawl.pages_crawled", "value": 3}"#);
708 normalize_assertions(&mut fixture);
709 assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
710 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
711 }
712
713 #[test]
714 fn normalize_assertions_rewrites_bare_pages_crawled() {
715 let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "pages_crawled", "value": 5}"#);
716 normalize_assertions(&mut fixture);
717 assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
718 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
719 }
720
721 #[test]
722 fn normalize_assertions_rewrites_pages_crawled_gte_to_count_min() {
723 let mut fixture = make_fixture_with_assertion(
724 r#"{"type": "greater_than_or_equal", "field": "crawl.pages_crawled", "value": 3}"#,
725 );
726 normalize_assertions(&mut fixture);
727 assert_eq!(fixture.assertions[0].assertion_type, "count_min");
728 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
729 }
730
731 #[test]
732 fn normalize_assertions_rewrites_pages_crawled_lte_to_count_max() {
733 let mut fixture = make_fixture_with_assertion(
734 r#"{"type": "less_than_or_equal", "field": "crawl.pages_crawled", "value": 7}"#,
735 );
736 normalize_assertions(&mut fixture);
737 assert_eq!(fixture.assertions[0].assertion_type, "count_max");
738 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
739 }
740
741 #[test]
742 fn normalize_assertions_rewrites_min_pages_to_count_min_on_pages() {
743 let mut fixture =
744 make_fixture_with_assertion(r#"{"type": "greater_than_or_equal", "field": "crawl.min_pages", "value": 2}"#);
745 normalize_assertions(&mut fixture);
746 assert_eq!(fixture.assertions[0].assertion_type, "count_min");
747 assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
748 }
749
750 #[test]
751 fn normalize_assertions_leaves_unrelated_fields_unchanged() {
752 let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "content", "value": "hi"}"#);
753 normalize_assertions(&mut fixture);
754 assert_eq!(fixture.assertions[0].assertion_type, "equals");
755 assert_eq!(fixture.assertions[0].field.as_deref(), Some("content"));
756 }
757
758 #[test]
759 fn test_fixture_without_mock_response() {
760 let json = r#"{
761 "id": "test_no_mock",
762 "description": "No mock",
763 "input": {},
764 "assertions": []
765 }"#;
766 let fixture: Fixture = serde_json::from_str(json).unwrap();
767 assert!(!fixture.needs_mock_server());
768 assert!(!fixture.is_streaming_mock());
769 }
770
771 #[test]
772 fn has_host_root_route_true_for_robots_path() {
773 let json = r#"{
774 "id": "robots_disallow_path",
775 "description": "Robots fixture",
776 "input": {
777 "mock_responses": [
778 {"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
779 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
780 ]
781 },
782 "assertions": []
783 }"#;
784 let fixture: Fixture = serde_json::from_str(json).unwrap();
785 assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
786 }
787
788 #[test]
789 fn has_host_root_route_true_for_sitemap_path() {
790 let json = r#"{
791 "id": "sitemap_index",
792 "description": "Sitemap fixture",
793 "input": {
794 "mock_responses": [
795 {"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
796 {"path": "/", "status_code": 200, "body_inline": "<html/>"}
797 ]
798 },
799 "assertions": []
800 }"#;
801 let fixture: Fixture = serde_json::from_str(json).unwrap();
802 assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
803 }
804
805 #[test]
806 fn has_host_root_route_false_for_data_json_path() {
807 let json = r#"{
808 "id": "data_endpoint",
809 "description": "Non-host-root fixture",
810 "input": {
811 "mock_responses": [
812 {"path": "/data.json", "status_code": 200, "body_inline": "{}"}
813 ]
814 },
815 "assertions": []
816 }"#;
817 let fixture: Fixture = serde_json::from_str(json).unwrap();
818 assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
819 }
820
821 #[test]
822 fn has_host_root_route_false_for_single_mock_response_schema() {
823 let json = r#"{
825 "id": "basic_chat",
826 "description": "Basic chat",
827 "mock_response": {"status": 200, "body": {}},
828 "input": {},
829 "assertions": []
830 }"#;
831 let fixture: Fixture = serde_json::from_str(json).unwrap();
832 assert!(
833 !fixture.has_host_root_route(),
834 "expected false for single mock_response schema"
835 );
836 }
837
838 #[test]
839 fn has_host_root_route_false_for_empty_mock_responses() {
840 let json = r#"{
841 "id": "empty_responses",
842 "description": "No mock_responses",
843 "input": {},
844 "assertions": []
845 }"#;
846 let fixture: Fixture = serde_json::from_str(json).unwrap();
847 assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
848 }
849}