| 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 | } |