Skip to main content

rws_server/test_client/
snapshot.rs

1//! Snapshot assertions for generated JSON: [`assert_openapi_snapshot`]
2//! compares an app's OpenAPI document with a file committed beside the
3//! tests, so a change to the API shows up in review as a change to that
4//! file.
5//!
6//! Both sides are normalized before comparing — parsed, object keys sorted,
7//! printed with two-space indentation — so key order and whitespace never
8//! count, and a mismatch fails with a line diff of the normalized texts.
9//!
10//! With `RWS_UPDATE_SNAPSHOTS=1` in the environment (any value but empty,
11//! `0` or `false`) the assertion writes the normalized document to the file
12//! instead — creating it and its directory if needed — and passes:
13//!
14//! ```text
15//! RWS_UPDATE_SNAPSHOTS=1 cargo test     # accept the current documents
16//! git diff tests/snapshots/             # review what changed
17//! ```
18//!
19//! A missing file fails (with that hint) when the variable isn't set, so a
20//! CI run never passes by writing its own snapshot. Relative paths are
21//! relative to the working directory, which under `cargo test` is the
22//! package's root.
23
24use std::path::Path;
25
26use super::{TestClient, TestResponse};
27use crate::application::Application;
28use crate::service_discovery::json_lite::{self, JsonValue};
29
30/// The environment variable that makes snapshot assertions write instead
31/// of compare.
32pub const UPDATE_VARIABLE: &str = "RWS_UPDATE_SNAPSHOTS";
33
34/// Something an OpenAPI document can be read from.
35pub trait OpenApiSource {
36    /// The document's JSON text.
37    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    /// The response body (e.g. of `GET /openapi.json`).
54    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    /// `GET /openapi.json` through the app — where
61    /// `AppWithState::openapi` serves the document.
62    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/// Compare an OpenAPI document with the snapshot at `path` (see the module
72/// docs for normalization and `RWS_UPDATE_SNAPSHOTS`). `source` is the
73/// document's text (`app.openapi_spec(&config)`), a `TestResponse` of it, or
74/// a [`TestClient`] whose app serves `/openapi.json`.
75///
76/// Panics with a diff on a mismatch, and when the document isn't JSON or
77/// has no `openapi` version field (a `404` page is no snapshot).
78///
79/// ```rust,no_run
80/// # extern crate rws_server as rust_web_server;
81/// # #[cfg(feature = "openapi")]
82/// # fn example() {
83/// use rust_web_server::app::App;
84/// use rust_web_server::openapi::OpenApiConfig;
85/// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
86/// use rust_web_server::test_client::{assert_openapi_snapshot, TestClient};
87///
88/// let app = App::with_state(())
89///     .get("/users/:id", |_req, _params, _conn, _state| {
90///         Response::get_response(STATUS_CODE_REASON_PHRASE.n200_ok, None, None)
91///     })
92///     .openapi(OpenApiConfig::new("Users", "1.0.0"));
93/// assert_openapi_snapshot(&TestClient::new(app), "tests/snapshots/openapi.json");
94/// # }
95/// ```
96#[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/// [`assert_openapi_snapshot`] for any JSON document (a JSON Schema, an MCP
112/// `tools/list` result, a response body).
113#[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/// Compare `value` with the snapshot at `path`, or write it there when
137/// `update` is set. `Err` is the failure message.
138#[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/// `value` as JSON text: object keys sorted (stable, so duplicates keep
180/// their order), two-space indentation, a final newline.
181#[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
189/// Recursion is bounded: `json_lite::parse` refuses nesting deeper than
190/// `json_lite::MAX_DEPTH`.
191fn 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
224/// The most lines either side may have for a full diff (an LCS table of
225/// `a × b` cells); past it the changed middle is shown as removed + added.
226const DIFF_CELLS: usize = 4_000_000;
227
228/// A unified-style line diff of `expected` → `actual`, with three lines of
229/// context around each change.
230#[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    // (tag, line): ' ' same, '-' only in expected, '+' only in actual.
239    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        // lcs[i][j]: the longest common subsequence of mid_a[i..], mid_b[j..].
243        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                // Removals before additions, as `diff -u` shows them.
261                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    // Keep changes and three lines of context; "…" marks what's left out.
275    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}