Skip to main content

max / quasi

12.1 KB · 357 lines History Blame Raw
1 //! Real requests through the protocol handler, with no tauri around it.
2 //!
3 //! [`serve`] takes an `http::Request` and answers an `http::Response`, which is
4 //! the whole of what the handler does once tauri has handed it the bytes. So
5 //! everything worth testing here is testable without a window, an event loop or
6 //! a display, and the part that is not, which is the registration itself, is
7 //! three lines of tauri API in [`Protocol::into_handler`].
8 //!
9 //! Decoding, status mapping and the retarget header are `quasi-http`'s and are
10 //! tested there. What is here is what this host adds: URL forms, the
11 //! passthrough, and the panic that would otherwise leave a webview hanging.
12
13 // Handlers take their parameters by value because `Handler` says so, and one
14 // that only reads them is the common case rather than an oversight.
15 #![allow(clippy::needless_pass_by_value)]
16
17 use std::sync::Arc;
18
19 use quasi_router::{Node, RegionKind, Response, RouteError, Router, Screen, Slot};
20
21 use super::{Context, Protocol, Served, Serves, serve};
22
23 /// An app with nothing in it. The router is what is under test.
24 struct App;
25
26 /// A renderer that says what it was handed, so a test can read it back.
27 struct Spy;
28
29 impl Serves for Spy {
30 fn suggestions(&self, field: &str, options: &[quasi_router::Candidate]) -> String {
31 format!(
32 "suggestions:{field}:{}",
33 options
34 .iter()
35 .map(|choice| choice.value.as_str())
36 .collect::<Vec<_>>()
37 .join(",")
38 )
39 }
40 fn screen(&self, screen: &Screen) -> String {
41 format!("screen:{}", screen.title)
42 }
43
44 fn fragment(&self, node: &Node) -> String {
45 match node {
46 Node::Text { text, .. } => format!("text:{text}"),
47 Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"),
48 other => format!("other:{other:?}"),
49 }
50 }
51 }
52
53 fn home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
54 Ok(Screen::sidebar_content("Home")
55 .with(Slot::new("content", RegionKind::Pane))
56 .into())
57 }
58
59 fn echo(_app: &App, request: quasi_router::Request) -> Result<Response, RouteError> {
60 // Captures, then what was sent, then the view it was sent from: the same
61 // order the one merged bag used to hold them in.
62 let joined = request
63 .captures
64 .iter()
65 .chain(request.payload.iter())
66 .chain(request.carried.iter())
67 .map(|(k, v)| format!("{k}={v}"))
68 .collect::<Vec<_>>()
69 .join(",");
70 Ok(Response::fragment("detail", Node::text(joined)))
71 }
72
73 fn denied(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
74 Err(RouteError::denied("not yours"))
75 }
76
77 fn boom(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
78 panic!("a handler that panics");
79 }
80
81 fn router() -> Router<App> {
82 Router::<App>::new()
83 .get("/", home)
84 .get("/task/{id}", echo)
85 .post("/task/{id}/edit", echo)
86 .post("/task/{id}/delete", denied)
87 .get("/boom", boom)
88 }
89
90 /// The protocol, taken apart into the context a request is answered against.
91 fn context(protocol: Protocol<App, Spy>) -> Context<App, Spy> {
92 Context {
93 router: protocol.router,
94 state: protocol.state,
95 render: protocol.render,
96 body_limit: protocol.body_limit,
97 passthrough: protocol.passthrough,
98 }
99 }
100
101 fn plain() -> Context<App, Spy> {
102 context(Protocol::new(
103 "quasi",
104 router(),
105 Arc::new(App),
106 Arc::new(Spy),
107 ))
108 }
109
110 /// A GET at a full custom-protocol URL, which is the only form a handler sees.
111 fn get(uri: &str) -> http::Request<Vec<u8>> {
112 http::Request::builder().uri(uri).body(Vec::new()).unwrap()
113 }
114
115 fn post_form(uri: &str, body: &str) -> http::Request<Vec<u8>> {
116 http::Request::builder()
117 .method("POST")
118 .uri(uri)
119 .header(
120 http::header::CONTENT_TYPE,
121 "application/x-www-form-urlencoded",
122 )
123 .body(body.as_bytes().to_vec())
124 .unwrap()
125 }
126
127 fn text(response: &http::Response<Vec<u8>>) -> String {
128 String::from_utf8(response.body().clone()).unwrap()
129 }
130
131 #[test]
132 fn the_url_a_window_is_pointed_at_is_the_scheme_over_localhost() {
133 let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy));
134 assert_eq!(protocol.url().as_str(), "quasi://localhost/");
135 }
136
137 #[test]
138 #[should_panic(expected = "is not a legal URL scheme")]
139 fn a_malformed_scheme_is_caught_at_construction() {
140 // Rather than at first paint, as a window that loads nothing and logs
141 // nothing about why.
142 let _ = Protocol::new("2fast", router(), Arc::new(App), Arc::new(Spy));
143 }
144
145 #[test]
146 fn a_request_at_the_scheme_url_routes_by_path_alone() {
147 let response = serve(&plain(), &get("quasi://localhost/"));
148 assert_eq!(response.status(), 200);
149 assert_eq!(text(&response), "screen:Home");
150 }
151
152 #[test]
153 fn the_windows_workaround_form_would_route_the_same_way() {
154 // wry reverts `http://quasi.localhost/...` to `quasi://localhost/...`
155 // before the handler is called, so this form should never actually arrive.
156 // Asserted anyway: the adapter reads the path and nothing else, so if wry
157 // ever stopped reverting, this is the line that says we still work.
158 let response = serve(&plain(), &get("http://quasi.localhost/task/7"));
159 assert_eq!(text(&response), "text:id=7");
160 }
161
162 #[test]
163 fn a_form_post_reaches_the_handler_with_its_body() {
164 // The finding the whole spike turned on: an action is a POST, and a webview
165 // that dropped the body would have taken decision 2 out.
166 let response = serve(
167 &plain(),
168 &post_form("quasi://localhost/task/7/edit", "title=new+title"),
169 );
170 assert_eq!(response.status(), 200);
171 assert!(text(&response).contains("title=new title"));
172 }
173
174 #[test]
175 fn a_fragment_names_the_region_it_replaces() {
176 let response = serve(&plain(), &get("quasi://localhost/task/7"));
177 assert_eq!(
178 response.headers().get(super::htmx::RETARGET).unwrap(),
179 "#detail"
180 );
181 }
182
183 #[test]
184 fn a_denial_is_a_403_the_webview_can_still_render() {
185 let response = serve(&plain(), &post_form("quasi://localhost/task/7/delete", ""));
186 assert_eq!(response.status(), 403);
187 assert_eq!(text(&response), "notice:Warning:not yours");
188 }
189
190 #[test]
191 fn an_unknown_path_is_a_404_that_still_renders_something() {
192 let response = serve(&plain(), &get("quasi://localhost/nowhere"));
193 assert_eq!(response.status(), 404);
194 assert!(text(&response).starts_with("notice:Warning:"));
195 }
196
197 #[test]
198 fn a_verb_the_description_layer_lacks_is_refused_with_allow() {
199 // PATCH, since `61e1b069` gave the layer DELETE and PUT.
200 let request = http::Request::builder()
201 .method("PATCH")
202 .uri("quasi://localhost/task/7")
203 .body(Vec::new())
204 .unwrap();
205 let response = serve(&plain(), &request);
206 assert_eq!(response.status(), 405);
207 assert_eq!(
208 response.headers().get(http::header::ALLOW).unwrap(),
209 "GET, POST, DELETE, PUT"
210 );
211 }
212
213 #[test]
214 fn a_panicking_handler_answers_rather_than_hanging_the_webview() {
215 // The difference that matters is not the 500. It is that there is a
216 // response at all: an unanswered responder is a webview waiting forever on
217 // a request nobody will finish.
218 let response = serve(&plain(), &get("quasi://localhost/boom"));
219 assert_eq!(response.status(), 500);
220 assert!(text(&response).starts_with("notice:Danger:"));
221 }
222
223 #[test]
224 fn an_oversized_form_is_refused_before_the_router_sees_it() {
225 let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).body_limit(16);
226 let body = "title=".to_owned() + &"x".repeat(1024);
227 let response = serve(
228 &context(protocol),
229 &post_form("quasi://localhost/task/7/edit", &body),
230 );
231 assert_eq!(response.status(), 413);
232 }
233
234 #[test]
235 fn a_passthrough_answers_before_the_router() {
236 let protocol =
237 Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).passthrough(|path| {
238 path.strip_prefix("/static/")
239 .map(|name| Served::new("text/css", format!("/* {name} */")))
240 });
241 let context = context(protocol);
242
243 let served = serve(&context, &get("quasi://localhost/static/styles.css"));
244 assert_eq!(served.status(), 200);
245 assert_eq!(
246 served.headers().get(http::header::CONTENT_TYPE).unwrap(),
247 "text/css"
248 );
249 assert_eq!(text(&served), "/* styles.css */");
250
251 // And a path it declines still routes.
252 let routed = serve(&context, &get("quasi://localhost/"));
253 assert_eq!(text(&routed), "screen:Home");
254 }
255
256 #[test]
257 fn a_passthrough_that_declines_everything_changes_nothing() {
258 let protocol =
259 Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).passthrough(|_| None);
260 let response = serve(&context(protocol), &get("quasi://localhost/task/7"));
261 assert_eq!(text(&response), "text:id=7");
262 }
263
264 #[test]
265 fn the_content_type_is_the_renderers_answer() {
266 let response = serve(&plain(), &get("quasi://localhost/"));
267 assert_eq!(
268 response.headers().get(http::header::CONTENT_TYPE).unwrap(),
269 "text/html; charset=utf-8"
270 );
271 }
272
273 /// The one screen the end-to-end test renders for real.
274 ///
275 /// The same description the axum adapter's own end-to-end test serves. Two
276 /// hosts, one description, and the only difference between the documents is
277 /// where the assets live — which is the stack's whole claim, and the reason
278 /// `Shell` takes an asset prefix rather than hard-coding one.
279 fn real_screen() -> Screen {
280 Screen::list_detail("Tasks", false)
281 .with(
282 Slot::new("list", RegionKind::Pane).with(Node::list([quasi_router::screen::Row::new(
283 "Write it down",
284 )
285 .activate(quasi_router::Action::get("/task/1"))])),
286 )
287 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")))
288 }
289
290 fn real_home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
291 Ok(real_screen().into())
292 }
293
294 #[test]
295 fn a_description_served_here_is_the_renderer_s_own_output() {
296 use quasi_http::Serves as _;
297
298 let render = quasi_webview::Webview::under("quasi://localhost/static");
299 let protocol = Protocol::new(
300 "quasi",
301 Router::<App>::new().get("/", real_home),
302 Arc::new(App),
303 Arc::new(render),
304 );
305 let context = Context {
306 router: protocol.router,
307 state: protocol.state,
308 render: protocol.render,
309 body_limit: protocol.body_limit,
310 passthrough: protocol.passthrough,
311 };
312
313 let response = serve(&context, &get("quasi://localhost/"));
314 assert_eq!(response.status(), 200);
315
316 let served = text(&response);
317 assert_eq!(
318 served,
319 quasi_webview::Webview::under("quasi://localhost/static").screen(&real_screen())
320 );
321
322 // The asset paths are the custom scheme's, which is the one thing that
323 // differs from the served case.
324 assert!(served.contains("src=\"quasi://localhost/static/htmx.min.js\""));
325 // Everything the description said is identical to what axum emits.
326 assert!(served.contains("hx-get=\"/task/1\""));
327 assert!(served.contains("id=\"detail\""));
328 }
329
330 #[test]
331 fn a_pending_protocol_answers_503_until_its_state_lands() {
332 let (protocol, late) = Protocol::<App, Spy>::pending("quasi", router(), Arc::new(Spy));
333 let ctx = context(protocol);
334
335 // The window between registering the scheme and setup filling the state in.
336 assert!(!late.is_set());
337 let early = serve(&ctx, &get("quasi://localhost/"));
338 assert_eq!(early.status(), 503);
339 assert!(text(&early).contains("starting"));
340
341 assert!(late.set(Arc::new(App)), "the first set wins");
342 assert!(late.is_set());
343 let served = serve(&ctx, &get("quasi://localhost/"));
344 assert_eq!(served.status(), 200);
345 assert_eq!(text(&served), "screen:Home");
346
347 // A second hand-over changes nothing rather than swapping the app out from
348 // under a request already in flight.
349 assert!(!late.set(Arc::new(App)));
350 }
351
352 #[test]
353 fn a_pending_protocol_names_the_same_url_as_an_eager_one() {
354 let (protocol, _late) = Protocol::<App, Spy>::pending("quasi", router(), Arc::new(Spy));
355 assert_eq!(protocol.url().as_str(), "quasi://localhost/");
356 }
357