1use 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#[derive(Clone)]
35pub struct UiHarness {
36 rest: RestHarness,
37}
38
39impl UiHarness {
40 pub fn new(router: Router) -> Self {
47 Self {
48 rest: RestHarness::new(router),
49 }
50 }
51
52 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 pub async fn get(&self, path: &str) -> Page {
78 Page(self.rest.get(path).send().await)
79 }
80
81 pub async fn navigate(&self, path: &str) -> Fragment {
85 Fragment(self.rest.get(path).header(NAV_HEADER, "1").send().await)
86 }
87
88 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 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
108pub struct Page(pub TestResponse);
110
111impl Page {
112 pub fn status(&self) -> StatusCode {
113 self.0.status
114 }
115
116 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 pub fn has_island(&self, id: &str) -> bool {
131 self.0
132 .body
133 .contains(&format!("data-island=\"{}\"", html_escape(id)))
134 }
135
136 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 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 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 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
192pub struct Fragment(pub TestResponse);
194
195impl Fragment {
196 pub fn status(&self) -> StatusCode {
197 self.0.status
198 }
199
200 pub fn payload(&self) -> serde_json::Value {
203 self.0.value()
204 }
205
206 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 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
239fn html_escape(value: &str) -> String {
241 value
242 .replace('&', "&")
243 .replace('<', "<")
244 .replace('>', ">")
245 .replace('"', """)
246}
247
248fn html_unescape(value: &str) -> String {
249 value
250 .replace(""", "\"")
251 .replace(">", ">")
252 .replace("<", "<")
253 .replace("&", "&")
254}