Skip to main content

autumn_plugin_motion/
plugin.rs

1//! [`MotionPlugin`]: installs the Motion animation assets in an Autumn app.
2//!
3//! The plugin installs the [`MOTION_ASSETS`] bundle through Autumn's
4//! `AppBuilder::plugin_assets` seam: the vendored Motion UMD build, the
5//! declarative init script, and the default stylesheet are served under
6//! `/static/_plugins/motion/` with content-hashed URLs. No configuration, no
7//! startup hooks.
8
9use std::borrow::Cow;
10
11use autumn_web::app::AppBuilder;
12use autumn_web::plugin::Plugin;
13
14use crate::assets::MOTION_ASSETS;
15
16/// The plugin name in Autumn diagnostics.
17pub const PLUGIN_NAME: &str = "autumn-plugin-motion";
18
19/// Installs the Motion animation assets in an Autumn app.
20///
21/// ```rust,no_run
22/// use autumn_plugin_motion::MotionPlugin;
23///
24/// # async fn run() {
25/// autumn_web::app()
26///     .plugin(MotionPlugin::new())
27///     .run()
28///     .await;
29/// # }
30/// ```
31///
32/// Then include [`motion_script`](crate::motion_script) in the page and
33/// animate with [`Motion`](crate::Motion) or raw `data-motion` attributes.
34#[derive(Debug, Default)]
35#[must_use]
36pub struct MotionPlugin;
37
38impl MotionPlugin {
39    /// Makes the plugin. It reads no configuration.
40    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    /// A test client for an app with only the plugin installed.
70    fn client() -> TestClient {
71        TestApp::new().plugin(MotionPlugin::new()).build()
72    }
73
74    /// The fingerprinted URL of a bundled file (test helper).
75    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            // Vendoring provenance is never served.
154            "/static/_plugins/motion/manifest.json",
155            // An older release's hash.
156            "/static/_plugins/motion/init.00000000.js",
157            "/static/_plugins/motion/nope.js",
158            // The pre-0.2 namespace is gone.
159            "/__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}