main
rs 151 lines 5.14 KB
Raw
1 use std::path::PathBuf;
2
3 fn get_fixture_json_dir() -> PathBuf {
4 if let Ok(dir) = std::env::var("FIXTURE_JSON_DIR") {
5 return PathBuf::from(dir);
6 }
7 // Default: fixtures checked in alongside the test
8 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
9 }
10
11 /// Recursively sort all keys in a JSON value for order-independent comparison.
12 fn normalize_json(value: &serde_json::Value) -> serde_json::Value {
13 match value {
14 serde_json::Value::Object(map) => {
15 let mut sorted: Vec<(String, serde_json::Value)> = map
16 .iter()
17 .map(|(k, v)| (k.clone(), normalize_json(v)))
18 .collect();
19 sorted.sort_by(|a, b| a.0.cmp(&b.0));
20 serde_json::Value::Object(sorted.into_iter().collect())
21 }
22 serde_json::Value::Array(arr) => {
23 serde_json::Value::Array(arr.iter().map(normalize_json).collect())
24 }
25 // Normalize numbers: f64 values like 1.0 should compare equal to integer 1
26 serde_json::Value::Number(n) => {
27 if let Some(f) = n.as_f64() {
28 if f.fract() == 0.0 && f.is_finite() && f.abs() < (i64::MAX as f64) {
29 serde_json::Value::Number(serde_json::Number::from(f as i64))
30 } else {
31 value.clone()
32 }
33 } else {
34 value.clone()
35 }
36 }
37 other => other.clone(),
38 }
39 }
40
41 fn compute_diff(original: &str, round_tripped: &str) -> String {
42 use similar::ChangeTag;
43 use similar::TextDiff;
44
45 let diff = TextDiff::from_lines(original, round_tripped);
46 let mut output = String::new();
47 let mut lines_written = 0;
48 const MAX_DIFF_LINES: usize = 50;
49
50 for change in diff.iter_all_changes() {
51 if lines_written >= MAX_DIFF_LINES {
52 output.push_str("... (diff truncated)\n");
53 break;
54 }
55 let sign = match change.tag() {
56 ChangeTag::Delete => "-",
57 ChangeTag::Insert => "+",
58 ChangeTag::Equal => continue,
59 };
60 output.push_str(&format!("{sign} {change}"));
61 lines_written += 1;
62 }
63
64 output
65 }
66
67 #[test]
68 fn round_trip_all_fixtures() {
69 let json_dir = get_fixture_json_dir();
70
71 let mut failures: Vec<(String, String)> = Vec::new();
72 let mut total = 0;
73 let mut passed = 0;
74
75 // Fixtures with known issues that can't be fixed in the AST crate:
76 // - lone-surrogate-string-values: contains lone Unicode surrogates (\uD800)
77 // that serde_json rejects during deserialization.
78 let known_failures: &[&str] = &["lone-surrogate-string-values"];
79
80 for entry in walkdir::WalkDir::new(&json_dir)
81 .into_iter()
82 .filter_map(|e| e.ok())
83 .filter(|e| {
84 e.path().extension().is_some_and(|ext| ext == "json")
85 && !e.path().to_string_lossy().ends_with(".scope.json")
86 && !e.path().to_string_lossy().ends_with(".renamed.json")
87 })
88 {
89 let fixture_name = entry
90 .path()
91 .strip_prefix(&json_dir)
92 .unwrap()
93 .display()
94 .to_string();
95 let original_json = std::fs::read_to_string(entry.path()).unwrap();
96
97 if known_failures.iter().any(|kf| fixture_name.contains(kf)) {
98 continue;
99 }
100
101 total += 1;
102
103 // Deserialize into our Rust types
104 let ast: react_compiler_ast::File = match serde_json::from_str(&original_json) {
105 Ok(ast) => ast,
106 Err(e) => {
107 failures.push((fixture_name, format!("Deserialization error: {e}")));
108 continue;
109 }
110 };
111
112 // Re-serialize back to JSON
113 let round_tripped = serde_json::to_string_pretty(&ast).unwrap();
114
115 // Normalize and compare
116 let original_value: serde_json::Value = serde_json::from_str(&original_json).unwrap();
117 let round_tripped_value: serde_json::Value = serde_json::from_str(&round_tripped).unwrap();
118
119 let original_normalized = normalize_json(&original_value);
120 let round_tripped_normalized = normalize_json(&round_tripped_value);
121
122 if original_normalized != round_tripped_normalized {
123 let orig_str = serde_json::to_string_pretty(&original_normalized).unwrap();
124 let rt_str = serde_json::to_string_pretty(&round_tripped_normalized).unwrap();
125 let diff = compute_diff(&orig_str, &rt_str);
126 failures.push((fixture_name, diff));
127 } else {
128 passed += 1;
129 }
130 }
131
132 println!("\n{passed}/{total} fixtures passed round-trip");
133
134 if !failures.is_empty() {
135 let show_count = failures.len().min(5);
136 let mut msg = format!(
137 "\n{} of {total} fixtures failed round-trip (showing first {show_count}):\n\n",
138 failures.len()
139 );
140 for (name, diff) in failures.iter().take(show_count) {
141 msg.push_str(&format!("--- {name} ---\n{diff}\n\n"));
142 }
143 if failures.len() > show_count {
144 msg.push_str(&format!(
145 "... and {} more failures\n",
146 failures.len() - show_count
147 ));
148 }
149 panic!("{msg}");
150 }
151 }