Skip to main content

max / quasi

10.8 KB · 319 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 screen(&self, screen: &Screen) -> String {
31 format!("screen:{}", screen.title)
32 }
33
34 fn fragment(&self, node: &Node) -> String {
35 match node {
36 Node::Text { text, .. } => format!("text:{text}"),
37 Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"),
38 other => format!("other:{other:?}"),
39 }
40 }
41 }
42
43 fn home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
44 Ok(Screen::sidebar_content("Home")
45 .with(Slot::new("content", RegionKind::Pane))
46 .into())
47 }
48
49 fn echo(_app: &App, request: quasi_router::Request) -> Result<Response, RouteError> {
50 // Captures, then what was sent, then the view it was sent from: the same
51 // order the one merged bag used to hold them in.
52 let joined = request
53 .captures
54 .iter()
55 .chain(request.payload.iter())
56 .chain(request.carried.iter())
57 .map(|(k, v)| format!("{k}={v}"))
58 .collect::<Vec<_>>()
59 .join(",");
60 Ok(Response::fragment("detail", Node::text(joined)))
61 }
62
63 fn denied(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
64 Err(RouteError::denied("not yours"))
65 }
66
67 fn boom(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
68 panic!("a handler that panics");
69 }
70
71 fn router() -> Router<App> {
72 Router::<App>::new()
73 .get("/", home)
74 .get("/task/{id}", echo)
75 .post("/task/{id}/edit", echo)
76 .post("/task/{id}/delete", denied)
77 .get("/boom", boom)
78 }
79
80 /// The protocol, taken apart into the context a request is answered against.
81 fn context(protocol: Protocol<App, Spy>) -> Context<App, Spy> {
82 Context {
83 router: protocol.router,
84 state: protocol.state,
85 render: protocol.render,
86 body_limit: protocol.body_limit,
87 passthrough: protocol.passthrough,
88 }
89 }
90
91 fn plain() -> Context<App, Spy> {
92 context(Protocol::new(
93 "quasi",
94 router(),
95 Arc::new(App),
96 Arc::new(Spy),
97 ))
98 }
99
100 /// A GET at a full custom-protocol URL, which is the only form a handler sees.
101 fn get(uri: &str) -> http::Request<Vec<u8>> {
102 http::Request::builder().uri(uri).body(Vec::new()).unwrap()
103 }
104
105 fn post_form(uri: &str, body: &str) -> http::Request<Vec<u8>> {
106 http::Request::builder()
107 .method("POST")
108 .uri(uri)
109 .header(
110 http::header::CONTENT_TYPE,
111 "application/x-www-form-urlencoded",
112 )
113 .body(body.as_bytes().to_vec())
114 .unwrap()
115 }
116
117 fn text(response: &http::Response<Vec<u8>>) -> String {
118 String::from_utf8(response.body().clone()).unwrap()
119 }
120
121 #[test]
122 fn the_url_a_window_is_pointed_at_is_the_scheme_over_localhost() {
123 let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy));
124 assert_eq!(protocol.url().as_str(), "quasi://localhost/");
125 }
126
127 #[test]
128 #[should_panic(expected = "is not a legal URL scheme")]
129 fn a_malformed_scheme_is_caught_at_construction() {
130 // Rather than at first paint, as a window that loads nothing and logs
131 // nothing about why.
132 let _ = Protocol::new("2fast", router(), Arc::new(App), Arc::new(Spy));
133 }
134
135 #[test]
136 fn a_request_at_the_scheme_url_routes_by_path_alone() {
137 let response = serve(&plain(), &get("quasi://localhost/"));
138 assert_eq!(response.status(), 200);
139 assert_eq!(text(&response), "screen:Home");
140 }
141
142 #[test]
143 fn the_windows_workaround_form_would_route_the_same_way() {
144 // wry reverts `http://quasi.localhost/...` to `quasi://localhost/...`
145 // before the handler is called, so this form should never actually arrive.
146 // Asserted anyway: the adapter reads the path and nothing else, so if wry
147 // ever stopped reverting, this is the line that says we still work.
148 let response = serve(&plain(), &get("http://quasi.localhost/task/7"));
149 assert_eq!(text(&response), "text:id=7");
150 }
151
152 #[test]
153 fn a_form_post_reaches_the_handler_with_its_body() {
154 // The finding the whole spike turned on: an action is a POST, and a webview
155 // that dropped the body would have taken decision 2 out.
156 let response = serve(
157 &plain(),
158 &post_form("quasi://localhost/task/7/edit", "title=new+title"),
159 );
160 assert_eq!(response.status(), 200);
161 assert!(text(&response).contains("title=new title"));
162 }
163
164 #[test]
165 fn a_fragment_names_the_region_it_replaces() {
166 let response = serve(&plain(), &get("quasi://localhost/task/7"));
167 assert_eq!(
168 response.headers().get(super::htmx::RETARGET).unwrap(),
169 "#detail"
170 );
171 }
172
173 #[test]
174 fn a_denial_is_a_403_the_webview_can_still_render() {
175 let response = serve(&plain(), &post_form("quasi://localhost/task/7/delete", ""));
176 assert_eq!(response.status(), 403);
177 assert_eq!(text(&response), "notice:Warning:not yours");
178 }
179
180 #[test]
181 fn an_unknown_path_is_a_404_that_still_renders_something() {
182 let response = serve(&plain(), &get("quasi://localhost/nowhere"));
183 assert_eq!(response.status(), 404);
184 assert!(text(&response).starts_with("notice:Warning:"));
185 }
186
187 #[test]
188 fn a_verb_the_description_layer_lacks_is_refused_with_allow() {
189 // PATCH, since `61e1b069` gave the layer DELETE and PUT.
190 let request = http::Request::builder()
191 .method("PATCH")
192 .uri("quasi://localhost/task/7")
193 .body(Vec::new())
194 .unwrap();
195 let response = serve(&plain(), &request);
196 assert_eq!(response.status(), 405);
197 assert_eq!(
198 response.headers().get(http::header::ALLOW).unwrap(),
199 "GET, POST, DELETE, PUT"
200 );
201 }
202
203 #[test]
204 fn a_panicking_handler_answers_rather_than_hanging_the_webview() {
205 // The difference that matters is not the 500. It is that there is a
206 // response at all: an unanswered responder is a webview waiting forever on
207 // a request nobody will finish.
208 let response = serve(&plain(), &get("quasi://localhost/boom"));
209 assert_eq!(response.status(), 500);
210 assert!(text(&response).starts_with("notice:Danger:"));
211 }
212
213 #[test]
214 fn an_oversized_form_is_refused_before_the_router_sees_it() {
215 let protocol = Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).body_limit(16);
216 let body = "title=".to_owned() + &"x".repeat(1024);
217 let response = serve(
218 &context(protocol),
219 &post_form("quasi://localhost/task/7/edit", &body),
220 );
221 assert_eq!(response.status(), 413);
222 }
223
224 #[test]
225 fn a_passthrough_answers_before_the_router() {
226 let protocol =
227 Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).passthrough(|path| {
228 path.strip_prefix("/static/")
229 .map(|name| Served::new("text/css", format!("/* {name} */")))
230 });
231 let context = context(protocol);
232
233 let served = serve(&context, &get("quasi://localhost/static/styles.css"));
234 assert_eq!(served.status(), 200);
235 assert_eq!(
236 served.headers().get(http::header::CONTENT_TYPE).unwrap(),
237 "text/css"
238 );
239 assert_eq!(text(&served), "/* styles.css */");
240
241 // And a path it declines still routes.
242 let routed = serve(&context, &get("quasi://localhost/"));
243 assert_eq!(text(&routed), "screen:Home");
244 }
245
246 #[test]
247 fn a_passthrough_that_declines_everything_changes_nothing() {
248 let protocol =
249 Protocol::new("quasi", router(), Arc::new(App), Arc::new(Spy)).passthrough(|_| None);
250 let response = serve(&context(protocol), &get("quasi://localhost/task/7"));
251 assert_eq!(text(&response), "text:id=7");
252 }
253
254 #[test]
255 fn the_content_type_is_the_renderers_answer() {
256 let response = serve(&plain(), &get("quasi://localhost/"));
257 assert_eq!(
258 response.headers().get(http::header::CONTENT_TYPE).unwrap(),
259 "text/html; charset=utf-8"
260 );
261 }
262
263 /// The one screen the end-to-end test renders for real.
264 ///
265 /// The same description the axum adapter's own end-to-end test serves. Two
266 /// hosts, one description, and the only difference between the documents is
267 /// where the assets live — which is the stack's whole claim, and the reason
268 /// `Shell` takes an asset prefix rather than hard-coding one.
269 fn real_screen() -> Screen {
270 Screen::list_detail("Tasks", false)
271 .with(
272 Slot::new("list", RegionKind::Pane).with(Node::list([quasi_router::screen::Row::new(
273 "Write it down",
274 )
275 .activate(quasi_router::Action::get("/task/1"))])),
276 )
277 .with(Slot::new("detail", RegionKind::Pane).with(Node::text("Nothing selected")))
278 }
279
280 fn real_home(_app: &App, _request: quasi_router::Request) -> Result<Response, RouteError> {
281 Ok(real_screen().into())
282 }
283
284 #[test]
285 fn a_description_served_here_is_the_renderer_s_own_output() {
286 use quasi_http::Serves as _;
287
288 let render = quasi_webview::Webview::under("quasi://localhost/static");
289 let protocol = Protocol::new(
290 "quasi",
291 Router::<App>::new().get("/", real_home),
292 Arc::new(App),
293 Arc::new(render),
294 );
295 let context = Context {
296 router: protocol.router,
297 state: protocol.state,
298 render: protocol.render,
299 body_limit: protocol.body_limit,
300 passthrough: protocol.passthrough,
301 };
302
303 let response = serve(&context, &get("quasi://localhost/"));
304 assert_eq!(response.status(), 200);
305
306 let served = text(&response);
307 assert_eq!(
308 served,
309 quasi_webview::Webview::under("quasi://localhost/static").screen(&real_screen())
310 );
311
312 // The asset paths are the custom scheme's, which is the one thing that
313 // differs from the served case.
314 assert!(served.contains("src=\"quasi://localhost/static/htmx.min.js\""));
315 // Everything the description said is identical to what axum emits.
316 assert!(served.contains("hx-get=\"/task/1\""));
317 assert!(served.contains("id=\"detail\""));
318 }
319