rws_server/test_client/
snapshot.rs1use std::path::Path;
25
26use super::{TestClient, TestResponse};
27use crate::application::Application;
28use crate::service_discovery::json_lite::{self, JsonValue};
29
30pub const UPDATE_VARIABLE: &str = "RWS_UPDATE_SNAPSHOTS";
33
34pub trait OpenApiSource {
36 fn openapi_json(&self) -> String;
38}
39
40impl OpenApiSource for str {
41 fn openapi_json(&self) -> String {
42 self.to_string()
43 }
44}
45
46impl OpenApiSource for String {
47 fn openapi_json(&self) -> String {
48 self.clone()
49 }
50}
51
52impl OpenApiSource for TestResponse {
53 fn openapi_json(&self) -> String {
55 String::from_utf8_lossy(self.body_bytes()).into_owned()
56 }
57}
58
59impl<A: Application> OpenApiSource for TestClient<A> {
60 fn openapi_json(&self) -> String {
63 let response = self.get("/openapi.json").send();
64 if response.status() != 200 {
65 panic!("GET /openapi.json answered {} — register the document with .openapi(OpenApiConfig::new(…))", response.status());
66 }
67 response.openapi_json()
68 }
69}
70
71#[track_caller]
97pub fn assert_openapi_snapshot<S: OpenApiSource + ?Sized>(source: &S, path: impl AsRef<Path>) {
98 let document = source.openapi_json();
99 let value = match json_lite::parse(&document) {
100 Ok(value) => value,
101 Err(e) => panic!("the OpenAPI document isn't JSON ({e}): {}", excerpt(&document)),
102 };
103 if value.get("openapi").and_then(JsonValue::as_str).is_none() {
104 panic!("not an OpenAPI document (no \"openapi\" version field): {}", excerpt(&document));
105 }
106 if let Err(message) = check_snapshot(&value, path.as_ref(), update_requested()) {
107 panic!("{message}");
108 }
109}
110
111#[track_caller]
114pub fn assert_json_snapshot(json: &str, path: impl AsRef<Path>) {
115 let value = match json_lite::parse(json) {
116 Ok(value) => value,
117 Err(e) => panic!("not JSON ({e}): {}", excerpt(json)),
118 };
119 if let Err(message) = check_snapshot(&value, path.as_ref(), update_requested()) {
120 panic!("{message}");
121 }
122}
123
124fn update_requested() -> bool {
125 std::env::var(UPDATE_VARIABLE).is_ok_and(|v| !matches!(v.trim(), "" | "0" | "false"))
126}
127
128fn excerpt(text: &str) -> String {
129 let mut cut: String = text.chars().take(200).collect();
130 if cut.len() < text.len() {
131 cut.push('…');
132 }
133 cut
134}
135
136#[doc(hidden)]
139pub fn check_snapshot(value: &JsonValue, path: &Path, update: bool) -> Result<(), String> {
140 let actual = normalize(value);
141 let existing = std::fs::read_to_string(path);
142 if update {
143 let unchanged = existing.as_ref().ok().and_then(|text| json_lite::parse(text).ok()).is_some_and(|old| normalize(&old) == actual);
144 if !unchanged || existing.as_deref().ok() != Some(actual.as_str()) {
145 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
146 std::fs::create_dir_all(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
147 }
148 std::fs::write(path, &actual).map_err(|e| format!("can't write {}: {e}", path.display()))?;
149 if !unchanged {
150 eprintln!("updated snapshot {}", path.display());
151 }
152 }
153 return Ok(());
154 }
155 let text = match existing {
156 Ok(text) => text,
157 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
158 return Err(format!(
159 "no snapshot at {} — run the test with {UPDATE_VARIABLE}=1 to write it, then commit it",
160 path.display()
161 ))
162 }
163 Err(e) => return Err(format!("can't read the snapshot {}: {e}", path.display())),
164 };
165 let expected = match json_lite::parse(&text) {
166 Ok(old) => normalize(&old),
167 Err(e) => return Err(format!("the snapshot {} isn't JSON ({e}) — rewrite it with {UPDATE_VARIABLE}=1", path.display())),
168 };
169 if expected == actual {
170 return Ok(());
171 }
172 Err(format!(
173 "the document differs from the snapshot {} (- snapshot, + now):\n{}\nIf the change is intended, run the test with {UPDATE_VARIABLE}=1 and commit the file.",
174 path.display(),
175 line_diff(&expected, &actual)
176 ))
177}
178
179#[doc(hidden)]
182pub fn normalize(value: &JsonValue) -> String {
183 let mut out = String::new();
184 write_value(value, 0, &mut out);
185 out.push('\n');
186 out
187}
188
189fn write_value(value: &JsonValue, indent: usize, out: &mut String) {
192 let pad = |out: &mut String, n: usize| out.extend(std::iter::repeat_n(' ', n * 2));
193 match value {
194 JsonValue::Array(items) if items.is_empty() => out.push_str("[]"),
195 JsonValue::Object(pairs) if pairs.is_empty() => out.push_str("{}"),
196 JsonValue::Array(items) => {
197 out.push_str("[\n");
198 for (i, item) in items.iter().enumerate() {
199 pad(out, indent + 1);
200 write_value(item, indent + 1, out);
201 out.push_str(if i + 1 < items.len() { ",\n" } else { "\n" });
202 }
203 pad(out, indent);
204 out.push(']');
205 }
206 JsonValue::Object(pairs) => {
207 let mut sorted: Vec<&(String, JsonValue)> = pairs.iter().collect();
208 sorted.sort_by(|a, b| a.0.cmp(&b.0));
209 out.push_str("{\n");
210 for (i, (key, item)) in sorted.iter().enumerate() {
211 pad(out, indent + 1);
212 out.push_str(&json_lite::quote(key));
213 out.push_str(": ");
214 write_value(item, indent + 1, out);
215 out.push_str(if i + 1 < sorted.len() { ",\n" } else { "\n" });
216 }
217 pad(out, indent);
218 out.push('}');
219 }
220 scalar => out.push_str(&json_lite::to_text(scalar)),
221 }
222}
223
224const DIFF_CELLS: usize = 4_000_000;
227
228#[doc(hidden)]
231pub fn line_diff(expected: &str, actual: &str) -> String {
232 let a: Vec<&str> = expected.lines().collect();
233 let b: Vec<&str> = actual.lines().collect();
234 let prefix = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
235 let suffix = a[prefix..].iter().rev().zip(b[prefix..].iter().rev()).take_while(|(x, y)| x == y).count();
236 let (mid_a, mid_b) = (&a[prefix..a.len() - suffix], &b[prefix..b.len() - suffix]);
237
238 let mut ops: Vec<(char, &str)> = a[..prefix].iter().map(|l| (' ', *l)).collect();
240 if (mid_a.len() + 1).saturating_mul(mid_b.len() + 1) <= DIFF_CELLS {
241 let (n, m) = (mid_a.len(), mid_b.len());
242 let mut lcs = vec![0u32; (n + 1) * (m + 1)];
244 for i in (0..n).rev() {
245 for j in (0..m).rev() {
246 lcs[i * (m + 1) + j] = if mid_a[i] == mid_b[j] {
247 lcs[(i + 1) * (m + 1) + j + 1] + 1
248 } else {
249 lcs[(i + 1) * (m + 1) + j].max(lcs[i * (m + 1) + j + 1])
250 };
251 }
252 }
253 let (mut i, mut j) = (0, 0);
254 while i < n || j < m {
255 if i < n && j < m && mid_a[i] == mid_b[j] {
256 ops.push((' ', mid_a[i]));
257 i += 1;
258 j += 1;
259 } else if i < n && (j == m || lcs[(i + 1) * (m + 1) + j] >= lcs[i * (m + 1) + j + 1]) {
260 ops.push(('-', mid_a[i]));
262 i += 1;
263 } else {
264 ops.push(('+', mid_b[j]));
265 j += 1;
266 }
267 }
268 } else {
269 ops.extend(mid_a.iter().map(|l| ('-', *l)));
270 ops.extend(mid_b.iter().map(|l| ('+', *l)));
271 }
272 ops.extend(a[a.len() - suffix..].iter().map(|l| (' ', *l)));
273
274 const CONTEXT: usize = 3;
276 let changed: Vec<usize> = ops.iter().enumerate().filter(|(_, (t, _))| *t != ' ').map(|(i, _)| i).collect();
277 if changed.is_empty() {
278 return String::new();
279 }
280 let near = |i: usize| changed.iter().any(|&c| c.abs_diff(i) <= CONTEXT);
281 let mut out = String::new();
282 let mut skipped = false;
283 for (i, (tag, line)) in ops.iter().enumerate() {
284 if *tag == ' ' && !near(i) {
285 skipped = true;
286 continue;
287 }
288 if skipped {
289 out.push_str(" …\n");
290 skipped = false;
291 }
292 out.push(*tag);
293 out.push(' ');
294 out.push_str(line);
295 out.push('\n');
296 }
297 if skipped {
298 out.push_str(" …\n");
299 }
300 out
301}