autumn_plugin_motion/
plugin.rs1use std::borrow::Cow;
10
11use autumn_web::app::AppBuilder;
12use autumn_web::plugin::Plugin;
13
14use crate::assets::MOTION_ASSETS;
15
16pub const PLUGIN_NAME: &str = "autumn-plugin-motion";
18
19#[derive(Debug, Default)]
35#[must_use]
36pub struct MotionPlugin;
37
38impl MotionPlugin {
39 pub const fn new() -> Self {
41 Self
42 }
43}
44
45impl Plugin for MotionPlugin {
46 fn name(&self) -> Cow<'static, str> {
47 Cow::Borrowed(PLUGIN_NAME)
48 }
49
50 fn build(self, app: AppBuilder) -> AppBuilder {
51 app.plugin_assets(&MOTION_ASSETS)
52 }
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58 use crate::assets::{INIT_JS, MOTION_CSS, MOTION_JS};
59 use autumn_web::assets::{PLUGIN_ASSETS_ROUTE_MARKER, asset_url};
60 use autumn_web::plugin_conformance::{ConformanceConfig, run_conformance};
61 use autumn_web::route_listing::{RouteClassification, RouteSource};
62 use autumn_web::test::{TestApp, TestClient};
63
64 const JS: &str = "text/javascript; charset=utf-8";
65 const CSS: &str = "text/css; charset=utf-8";
66 const IMMUTABLE: &str = "public, max-age=31536000, immutable";
67 const REVALIDATE: &str = "public, max-age=0, must-revalidate";
68
69 fn client() -> TestClient {
71 TestApp::new().plugin(MotionPlugin::new()).build()
72 }
73
74 fn url(path: &str) -> String {
76 MOTION_ASSETS.url(path)
77 }
78
79 #[tokio::test]
80 async fn motion_js_serves_at_its_fingerprinted_url() {
81 let response = client().get(&url(MOTION_JS)).send().await;
82 response
83 .assert_ok()
84 .assert_header("content-type", JS)
85 .assert_header("cache-control", IMMUTABLE);
86 assert!(
87 response.body.starts_with(b"!function"),
88 "serves the minified UMD build"
89 );
90 assert!(
91 response
92 .body
93 .windows(b"window.Motion".len())
94 .any(|w| w == b"window.Motion"),
95 "UMD build targets the Motion global"
96 );
97 }
98
99 #[tokio::test]
100 async fn init_js_serves_at_its_fingerprinted_url() {
101 let response = client().get(&url(INIT_JS)).send().await;
102 response
103 .assert_ok()
104 .assert_header("content-type", JS)
105 .assert_header("cache-control", IMMUTABLE);
106 let text = response.text();
107 assert!(
108 text.contains("htmx:afterSwap"),
109 "init script re-scans on htmx swaps"
110 );
111 assert!(
112 text.contains("prefers-reduced-motion"),
113 "init script respects reduced motion"
114 );
115 }
116
117 #[tokio::test]
118 async fn motion_css_serves_at_its_fingerprinted_url() {
119 let response = client().get(&url(MOTION_CSS)).send().await;
120 response
121 .assert_ok()
122 .assert_header("content-type", CSS)
123 .assert_header("cache-control", IMMUTABLE);
124 assert!(
125 response.text().contains(".motion-progress"),
126 "stylesheet styles the progress bar"
127 );
128 }
129
130 #[tokio::test]
131 async fn plain_urls_serve_with_revalidation_and_etags() {
132 let client = client();
133 for path in [MOTION_JS, INIT_JS, MOTION_CSS] {
134 let plain = format!("/static/_plugins/motion/{path}");
135 let response = client.get(&plain).send().await;
136 response
137 .assert_ok()
138 .assert_header("cache-control", REVALIDATE);
139 let etag = response.header("etag").expect("etag is set").to_owned();
140 client
141 .get(&plain)
142 .header("if-none-match", &etag)
143 .send()
144 .await
145 .assert_status(304);
146 }
147 }
148
149 #[tokio::test]
150 async fn unbundled_and_stale_paths_are_not_found() {
151 let client = client();
152 for path in [
153 "/static/_plugins/motion/manifest.json",
155 "/static/_plugins/motion/init.00000000.js",
157 "/static/_plugins/motion/nope.js",
158 "/__motion/init.js",
160 ] {
161 client.get(path).send().await.assert_status(404);
162 }
163 }
164
165 #[tokio::test]
166 async fn asset_url_resolves_the_installed_bundle() {
167 let _client = client();
168 for path in [MOTION_JS, INIT_JS, MOTION_CSS] {
169 assert_eq!(asset_url(&format!("_plugins/motion/{path}")), url(path));
170 }
171 }
172
173 #[test]
174 fn bundle_routes_are_public_plugin_routes() {
175 let app = autumn_web::app().plugin(MotionPlugin::new());
176 let infos = app.plugin_route_infos().expect("route infos");
177 let asset_routes: Vec<_> = infos
178 .iter()
179 .filter(|info| info.path.starts_with("/static/_plugins/motion/"))
180 .collect();
181 assert_eq!(
182 asset_routes.len(),
183 6,
184 "three files, two URLs each: {infos:?}"
185 );
186 for info in asset_routes {
187 assert_eq!(info.method, "GET");
188 assert_eq!(info.classification, RouteClassification::Public);
189 assert_eq!(info.middleware, [PLUGIN_ASSETS_ROUTE_MARKER]);
190 assert_eq!(info.source, RouteSource::Plugin(PLUGIN_NAME.to_owned()));
191 }
192 }
193
194 #[test]
195 fn plugin_passes_conformance() {
196 let app = autumn_web::app().plugin(MotionPlugin::new());
197 let infos = app.plugin_route_infos().expect("route infos");
198 let report = run_conformance(&ConformanceConfig::new(PLUGIN_NAME), &infos);
199 assert!(report.passed(), "{}", report.to_text_report());
200 }
201
202 #[tokio::test]
203 async fn installing_the_plugin_twice_is_harmless() {
204 let client = TestApp::new()
205 .plugin(MotionPlugin::new())
206 .plugin(MotionPlugin::new())
207 .build();
208 client.get(&url(INIT_JS)).send().await.assert_ok();
209 }
210}