Skip to main content

wabot_testing/
ui.rs

1//! Drive UI controllers in a test. Port of
2//! `wabot-ts/src/testing/uiHarness.ts`.
3//!
4//! UI controllers are served by the REST stack, so this builds on
5//! [`RestHarness`] and adds what a page test actually asserts on:
6//! rendered HTML, the islands in it, boosted-navigation fragments and
7//! action responses.
8//!
9//! Islands are **not** hydrated — there is no browser here. What the
10//! harness checks is the server's half of the contract: that a host
11//! element is emitted with the right id and props, which is what the
12//! client runtime needs to mount anything at all. A mismatch there is
13//! the failure that shows up as "the island silently never appeared".
14
15use serde::Serialize;
16use wabot_feature_rest_controller::axum::http::StatusCode;
17use wabot_feature_rest_controller::axum::Router;
18use wabot_feature_ui_controller::nav::NAV_HEADER;
19use wabot_feature_ui_controller::runtime::action_route;
20
21use crate::rest::{RestHarness, TestResponse};
22
23/// Mounts UI routes and exercises the real pipeline: middlewares,
24/// extractors, rendering, the static cache and actions.
25///
26/// ```ignore
27/// let harness = UiHarness::new(NotesController::register_ui_routes(&container, ui_router()));
28///
29/// let page = harness.get("/notes").await;
30/// page.assert_ok();
31/// assert!(page.contains("<h1>Notes</h1>"));
32/// assert!(page.has_island("notes-form"));
33/// ```
34#[derive(Clone)]
35pub struct UiHarness {
36    rest: RestHarness,
37}
38
39impl UiHarness {
40    /// Build from a router — usually
41    /// `MyController::register_ui_routes(&container, ui_router())`.
42    ///
43    /// Pass the real `ui_router()` rather than `Router::new()` if the
44    /// test touches the client runtime or boosted navigation, since
45    /// that is where `/_wabot/client.js` lives.
46    pub fn new(router: Router) -> Self {
47        Self {
48            rest: RestHarness::new(router),
49        }
50    }
51
52    /// The underlying REST harness, for headers, cookies and anything
53    /// page-shaped this doesn't cover.
54    pub fn rest(&self) -> &RestHarness {
55        &self.rest
56    }
57
58    pub fn with_header(&self, name: impl Into<String>, value: impl Into<String>) -> Self {
59        Self {
60            rest: self.rest.with_header(name, value),
61        }
62    }
63
64    pub fn with_bearer(&self, token: impl std::fmt::Display) -> Self {
65        Self {
66            rest: self.rest.with_bearer(token),
67        }
68    }
69
70    pub fn with_cookie(&self, name: &str, value: impl std::fmt::Display) -> Self {
71        Self {
72            rest: self.rest.with_cookie(name, value),
73        }
74    }
75
76    /// GET a view and get the rendered document.
77    pub async fn get(&self, path: &str) -> Page {
78        Page(self.rest.get(path).send().await)
79    }
80
81    /// GET a view the way the client runtime does after the first
82    /// load: `X-Wabot-Nav: 1`, answered with a JSON fragment instead
83    /// of a document.
84    pub async fn navigate(&self, path: &str) -> Fragment {
85        Fragment(self.rest.get(path).header(NAV_HEADER, "1").send().await)
86    }
87
88    /// POST to an `#[action]`, addressing it the way the client
89    /// runtime does — `<controller>/_action/<name>`, built with the
90    /// framework's own route helper so the convention can't drift.
91    pub async fn action<T: Serialize>(&self, base: &str, name: &str, body: &T) -> TestResponse {
92        self.rest
93            .post(&action_route(base, name))
94            .json(body)
95            .send()
96            .await
97    }
98
99    /// The client runtime, to check it is being served at all.
100    pub async fn client_runtime(&self) -> TestResponse {
101        self.rest
102            .get(wabot_feature_ui_controller::nav::CLIENT_RUNTIME_PATH)
103            .send()
104            .await
105    }
106}
107
108/// A rendered page.
109pub struct Page(pub TestResponse);
110
111impl Page {
112    pub fn status(&self) -> StatusCode {
113        self.0.status
114    }
115
116    /// The HTML, for a bespoke assertion.
117    pub fn html(&self) -> &str {
118        &self.0.body
119    }
120
121    pub fn contains(&self, fragment: &str) -> bool {
122        self.0.body.contains(fragment)
123    }
124
125    /// Whether an island host was emitted for `id`.
126    ///
127    /// The server's whole job for an island is putting this element in
128    /// the document; if it isn't there the island never mounts, and
129    /// nothing else in a test would notice.
130    pub fn has_island(&self, id: &str) -> bool {
131        self.0
132            .body
133            .contains(&format!("data-island=\"{}\"", html_escape(id)))
134    }
135
136    /// The props handed to an island, decoded from the attribute.
137    ///
138    /// `None` when there is no such island, so a test distinguishes
139    /// "not rendered" from "rendered with nothing".
140    pub fn island_props(&self, id: &str) -> Option<serde_json::Value> {
141        let marker = format!("data-island=\"{}\"", html_escape(id));
142        let start = self.0.body.find(&marker)? + marker.len();
143        let rest = &self.0.body[start..];
144        let props_at = rest.find("data-props=\"")? + "data-props=\"".len();
145        let rest = &rest[props_at..];
146        let end = rest.find('"')?;
147        serde_json::from_str(&html_unescape(&rest[..end])).ok()
148    }
149
150    /// Ids of every island host in the document, in order.
151    pub fn islands(&self) -> Vec<String> {
152        let mut ids = Vec::new();
153        let mut rest = self.0.body.as_str();
154        while let Some(at) = rest.find("data-island=\"") {
155            rest = &rest[at + "data-island=\"".len()..];
156            if let Some(end) = rest.find('"') {
157                ids.push(html_unescape(&rest[..end]));
158                rest = &rest[end..];
159            } else {
160                break;
161            }
162        }
163        ids
164    }
165
166    pub fn header(&self, name: &str) -> Option<&str> {
167        self.0.header(name)
168    }
169
170    /// Assert the status, showing the body when it doesn't match.
171    pub fn assert_status(&self, expected: StatusCode) -> &Self {
172        self.0.assert_status(expected);
173        self
174    }
175
176    pub fn assert_ok(&self) -> &Self {
177        self.assert_status(StatusCode::OK)
178    }
179
180    /// Assert the HTML contains `fragment`, printing the document when
181    /// it doesn't — a page test's failure is unreadable without it.
182    pub fn assert_contains(&self, fragment: &str) -> &Self {
183        assert!(
184            self.contains(fragment),
185            "expected the page to contain {fragment:?}, got:\n{}",
186            self.0.body
187        );
188        self
189    }
190}
191
192/// A boosted-navigation response.
193pub struct Fragment(pub TestResponse);
194
195impl Fragment {
196    pub fn status(&self) -> StatusCode {
197        self.0.status
198    }
199
200    /// The parsed payload — `html`, `title`, `meta`, `scripts`,
201    /// `styles`, `maxAge`.
202    pub fn payload(&self) -> serde_json::Value {
203        self.0.value()
204    }
205
206    /// The outlet contents. This is the assertion that matters: a
207    /// fragment carrying a whole document would mean the shell gets
208    /// nested inside itself on every soft navigation.
209    pub fn html(&self) -> String {
210        self.payload()["html"]
211            .as_str()
212            .unwrap_or_default()
213            .to_string()
214    }
215
216    pub fn title(&self) -> Option<String> {
217        self.payload()["title"].as_str().map(str::to_string)
218    }
219
220    /// Island modules the client must import before hydrating.
221    pub fn scripts(&self) -> Vec<String> {
222        self.payload()["scripts"]
223            .as_array()
224            .map(|items| {
225                items
226                    .iter()
227                    .filter_map(|s| s.as_str().map(str::to_string))
228                    .collect()
229            })
230            .unwrap_or_default()
231    }
232
233    pub fn assert_ok(&self) -> &Self {
234        self.0.assert_ok();
235        self
236    }
237}
238
239/// The escaping the island helper applies to attribute values.
240fn html_escape(value: &str) -> String {
241    value
242        .replace('&', "&amp;")
243        .replace('<', "&lt;")
244        .replace('>', "&gt;")
245        .replace('"', "&quot;")
246}
247
248fn html_unescape(value: &str) -> String {
249    value
250        .replace("&quot;", "\"")
251        .replace("&gt;", ">")
252        .replace("&lt;", "<")
253        .replace("&amp;", "&")
254}