Skip to main content

ssg/server/
livereload.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Live-reload script injection plugin.
5//!
6//! Injects a WebSocket-based live-reload client into all HTML files in
7//! the site directory when the development server starts.
8//!
9//! # How it works
10//!
11//! 1. The `LiveReloadPlugin` hooks into the `on_serve` lifecycle event.
12//! 2. It walks all HTML files in the site directory.
13//! 3. It injects a `<script>` tag before `</body>` that opens a WebSocket
14//!    connection to a configurable port (default 35729).
15//! 4. When the server sends a `"reload"` message, the page reloads.
16//! 5. On disconnect, the script auto-reconnects with exponential backoff
17//!    (1s, 2s, 4s, capped at 10s) and shows a small "Connecting..."
18//!    indicator in the bottom-right corner.
19
20use crate::error::{PathErrorExt, SsgError};
21use crate::plugin::{Plugin, PluginContext};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25/// Default WebSocket port for the live-reload server.
26pub const DEFAULT_PORT: u16 = 35729;
27
28/// Maximum number of HTML files to process.
29const MAX_FILES: usize = 50_000;
30
31/// Marker attribute used to detect whether the script has already been injected.
32const MARKER: &str = "ssg-livereload";
33
34/// Plugin that injects a live-reload script into all HTML files.
35///
36/// The injected script opens a WebSocket connection and reloads the page
37/// when it receives a `"reload"` message. It reconnects automatically
38/// with exponential backoff on disconnect.
39///
40/// # Example
41///
42/// ```rust
43/// use ssg::plugin::PluginManager;
44/// use ssg::livereload::LiveReloadPlugin;
45///
46/// let mut pm = PluginManager::new();
47/// pm.register(LiveReloadPlugin::new());
48/// ```
49#[derive(Debug, Clone, Copy)]
50pub struct LiveReloadPlugin {
51    /// WebSocket port the live-reload client connects to.
52    port: u16,
53}
54
55impl LiveReloadPlugin {
56    /// Creates a new `LiveReloadPlugin` with the default port (35729).
57    ///
58    /// # Examples
59    ///
60    /// ```rust
61    /// use ssg::livereload::{LiveReloadPlugin, DEFAULT_PORT};
62    ///
63    /// let p = LiveReloadPlugin::new();
64    /// assert_eq!(p.port(), DEFAULT_PORT);
65    /// ```
66    #[must_use]
67    pub const fn new() -> Self {
68        Self { port: DEFAULT_PORT }
69    }
70
71    /// Creates a new `LiveReloadPlugin` with a custom WebSocket port.
72    ///
73    /// # Examples
74    ///
75    /// ```rust
76    /// use ssg::livereload::LiveReloadPlugin;
77    ///
78    /// let p = LiveReloadPlugin::with_port(8765);
79    /// assert_eq!(p.port(), 8765);
80    /// ```
81    #[must_use]
82    pub const fn with_port(port: u16) -> Self {
83        Self { port }
84    }
85
86    /// Returns the configured port.
87    ///
88    /// # Examples
89    ///
90    /// ```rust
91    /// use ssg::livereload::LiveReloadPlugin;
92    ///
93    /// let p = LiveReloadPlugin::with_port(8080);
94    /// assert_eq!(p.port(), 8080);
95    /// ```
96    #[must_use]
97    pub const fn port(&self) -> u16 {
98        self.port
99    }
100}
101
102impl Default for LiveReloadPlugin {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl Plugin for LiveReloadPlugin {
109    fn name(&self) -> &'static str {
110        "livereload"
111    }
112
113    fn on_serve(&self, ctx: &PluginContext) -> Result<(), SsgError> {
114        if !ctx.site_dir.exists() {
115            return Ok(());
116        }
117
118        let html_files = collect_html_files(&ctx.site_dir)?;
119        if html_files.is_empty() {
120            return Ok(());
121        }
122
123        for path in &html_files {
124            inject_livereload(path, self.port)?;
125        }
126
127        println!(
128            "[livereload] Injected live-reload script into {} HTML file(s) (port {})",
129            html_files.len(),
130            self.port,
131        );
132        Ok(())
133    }
134}
135
136/// Collect all `.html` files under `dir` (iterative, bounded).
137fn collect_html_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
138    crate::walk::walk_files_bounded_count(dir, "html", MAX_FILES)
139}
140
141/// Inject the live-reload script into a single HTML file.
142///
143/// Inserts a `<script>` block before `</body>`. The script:
144/// 1. Opens a WebSocket to `ws://localhost:{port}`
145/// 2. Reloads on receiving a `"reload"` message
146/// 3. Reconnects with exponential backoff (1s, 2s, 4s, max 10s)
147/// 4. Shows a "Connecting..." indicator during reconnection
148///
149/// The injection is idempotent — if the marker is already present,
150/// the file is left unchanged.
151fn inject_livereload(path: &Path, port: u16) -> Result<(), SsgError> {
152    let html = fs::read_to_string(path).with_path(path)?;
153
154    if html.contains(MARKER) {
155        return Ok(()); // Already injected
156    }
157
158    let script = livereload_script(port);
159
160    let injected = if let Some(pos) = html.rfind("</body>") {
161        format!("{}{}{}", &html[..pos], script, &html[pos..])
162    } else {
163        format!("{html}{script}")
164    };
165
166    fs::write(path, injected).with_path(path)?;
167    Ok(())
168}
169
170/// Generate the live-reload script tag for a given port.
171fn livereload_script(port: u16) -> String {
172    format!(
173        r"
174<!-- SSG Live-Reload -->
175<script data-ssg-livereload>
176(function(){{
177  var url='ws://localhost:{port}',delay=1000,maxDelay=10000,indicator=null;
178  try{{var sp=sessionStorage.getItem('ssg-scroll');if(sp){{sessionStorage.removeItem('ssg-scroll');var p=JSON.parse(sp);setTimeout(function(){{scrollTo(p.x,p.y);}},50);}}}}catch(se){{}}
179  function showIndicator(){{
180    if(indicator)return;
181    indicator=document.createElement('div');
182    indicator.id='ssg-livereload';
183    indicator.textContent='Connecting\u2026';
184    indicator.style.cssText='position:fixed;bottom:8px;right:8px;z-index:99999;'
185      +'background:rgba(0,0,0,0.75);color:#fff;padding:6px 12px;border-radius:6px;'
186      +'font:13px/1 -apple-system,system-ui,sans-serif;pointer-events:none';
187    document.body.appendChild(indicator);
188  }}
189  function hideIndicator(){{
190    if(indicator){{indicator.remove();indicator=null;}}
191  }}
192  function showOverlay(msg){{
193    hideOverlay();
194    var d=document.createElement('div');
195    d.id='ssg-error-overlay';
196    d.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.85);color:#fff;font-family:monospace;font-size:14px;z-index:999999;padding:32px;overflow:auto;';
197    var c=document.createElement('div');
198    c.style.cssText='max-width:800px;margin:0 auto;';
199    var hdr=document.createElement('div');
200    hdr.style.cssText='display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;';
201    var title=document.createElement('span');
202    title.style.cssText='color:#ff6b6b;font-size:18px;font-weight:bold;';
203    title.textContent='Build Error';
204    var btn=document.createElement('button');
205    btn.textContent='\u2715';
206    btn.style.cssText='background:none;border:1px solid #666;color:#fff;padding:4px 12px;cursor:pointer;border-radius:4px;';
207    btn.addEventListener('click',hideOverlay);
208    hdr.appendChild(title);
209    hdr.appendChild(btn);
210    c.appendChild(hdr);
211    if(msg.file){{
212      var fp=document.createElement('div');
213      fp.style.cssText='color:#ffd93d;margin-bottom:8px;';
214      fp.textContent=msg.file+(msg.line?':'+msg.line:'');
215      c.appendChild(fp);
216    }}
217    var pre=document.createElement('pre');
218    pre.style.cssText='background:#1a1a2e;padding:16px;border-radius:8px;border-left:4px solid #ff6b6b;overflow-x:auto;white-space:pre-wrap;word-break:break-word;';
219    pre.textContent=msg.message;
220    c.appendChild(pre);
221    d.appendChild(c);
222    document.body.appendChild(d);
223  }}
224  function hideOverlay(){{var e=document.getElementById('ssg-error-overlay');if(e)e.remove();}}
225  function connect(){{
226    try{{
227      var ws=new WebSocket(url);
228      ws.onopen=function(){{delay=1000;hideIndicator();}};
229      ws.onmessage=function(e){{
230        if(e.data==='reload'){{hideOverlay();try{{sessionStorage.setItem('ssg-scroll',JSON.stringify({{x:scrollX,y:scrollY}}));}}catch(se){{}}
231          var doReload=function(){{location.reload();}};
232          // Issue #547: when the transitions client is active, wrap the
233          // reload in a view transition so structural HMR frames cross-fade.
234          if(typeof window.__ssgTransitionsReload==='function'){{
235            try{{window.__ssgTransitionsReload(doReload);}}catch(_){{doReload();}}
236          }}else{{doReload();}}
237        }}
238        try{{var msg=JSON.parse(e.data);
239        if(msg.type==='error'){{showOverlay(msg);}}
240        else if(msg.type==='clear-error'){{hideOverlay();}}
241        else if(msg.type==='css-reload'||msg.type==='hmr-css'){{
242          var links=document.querySelectorAll('link[rel=stylesheet]');
243          links.forEach(function(link){{
244            var href=link.getAttribute('href');
245            if(href){{link.setAttribute('href',href.split('?')[0]+'?v='+Date.now());}}
246          }});
247        }}
248        else if(msg.type==='hmr-html'){{
249          // Partial HTML reload (AC3 / AC4) — fetch the current page
250          // fresh and swap the <main> body so scroll position and
251          // any form state outside <main> are preserved. If the URL
252          // is not in the broadcast paths list we still do nothing
253          // (other tabs handle their own pages). If <head> needs an
254          // update the server sends 'reload' instead.
255          var here=location.pathname;
256          var hit=Array.isArray(msg.paths)&&msg.paths.some(function(p){{
257            return p===here||p===here.replace(/\/$/,'')||p+'/'===here;
258          }});
259          if(!hit){{return;}}
260          hideOverlay();
261          fetch(location.href,{{cache:'no-store'}}).then(function(r){{
262            return r.text();
263          }}).then(function(html){{
264            var parser=new DOMParser();
265            var doc=parser.parseFromString(html,'text/html');
266            var freshMain=doc.querySelector('main');
267            var liveMain=document.querySelector('main');
268            if(freshMain&&liveMain){{
269              liveMain.replaceWith(freshMain);
270            }}else if(doc.body&&document.body){{
271              document.body.innerHTML=doc.body.innerHTML;
272            }}else{{
273              location.reload();
274            }}
275          }}).catch(function(){{location.reload();}});
276        }}
277        else if(msg.type==='reload'){{
278          hideOverlay();
279          try{{sessionStorage.setItem('ssg-scroll',JSON.stringify({{x:scrollX,y:scrollY}}));}}catch(se){{}}
280          location.reload();
281        }}
282        }}catch(x){{}}
283      }};
284      ws.onclose=function(){{
285        var d=delay;
286        delay=Math.min(delay*2,maxDelay);
287        setTimeout(connect,d);
288      }};
289      ws.onerror=function(){{}};
290    }}catch(e){{}}
291  }}
292  // Only connect in development (localhost) and limit retries
293  // to avoid console error spam when the WS server is not running
294  if(location.hostname==='localhost'||location.hostname==='127.0.0.1'||location.hostname==='0.0.0.0'){{
295    if(document.readyState==='loading'){{
296      document.addEventListener('DOMContentLoaded',connect);
297    }}else{{
298      connect();
299    }}
300  }}
301}})();
302</script>
303"
304    )
305}
306
307/// Returns a WebSocket message for CSS-only reload.
308#[must_use]
309#[allow(dead_code)]
310pub fn css_reload_message(css_path: &str) -> String {
311    serde_json::json!({
312        "type": "css-reload",
313        "file": css_path,
314    })
315    .to_string()
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use tempfile::tempdir;
322
323    fn make_html(body: &str) -> String {
324        format!(
325            "<html><head><title>Test</title></head>\
326             <body>{body}</body></html>"
327        )
328    }
329
330    #[test]
331    fn inject_adds_script() {
332        let tmp = tempdir().expect("tempdir");
333        let path = tmp.path().join("page.html");
334        fs::write(&path, make_html("<p>Hello</p>")).expect("write");
335
336        inject_livereload(&path, DEFAULT_PORT).expect("inject");
337
338        let result = fs::read_to_string(&path).expect("read");
339        assert!(result.contains(MARKER));
340        assert!(result.contains("WebSocket"));
341        assert!(result.contains("35729"));
342        assert!(result.contains("location.reload()"));
343    }
344
345    #[test]
346    fn inject_before_closing_body() {
347        let tmp = tempdir().expect("tempdir");
348        let path = tmp.path().join("page.html");
349        fs::write(&path, make_html("<p>Hi</p>")).expect("write");
350
351        inject_livereload(&path, DEFAULT_PORT).expect("inject");
352
353        let result = fs::read_to_string(&path).expect("read");
354        let script_pos = result.find(MARKER).unwrap();
355        let body_pos = result.rfind("</body>").unwrap();
356        assert!(script_pos < body_pos);
357    }
358
359    #[test]
360    fn inject_idempotent() {
361        let tmp = tempdir().expect("tempdir");
362        let path = tmp.path().join("page.html");
363        fs::write(&path, make_html("<p>Hi</p>")).expect("write");
364
365        inject_livereload(&path, DEFAULT_PORT).expect("inject");
366        let first = fs::read_to_string(&path).expect("read");
367
368        inject_livereload(&path, DEFAULT_PORT).expect("inject");
369        let second = fs::read_to_string(&path).expect("read");
370
371        assert_eq!(first, second);
372    }
373
374    #[test]
375    fn inject_custom_port() {
376        let tmp = tempdir().expect("tempdir");
377        let path = tmp.path().join("page.html");
378        fs::write(&path, make_html("<p>Hi</p>")).expect("write");
379
380        inject_livereload(&path, 9999).expect("inject");
381
382        let result = fs::read_to_string(&path).expect("read");
383        assert!(result.contains("9999"));
384        assert!(!result.contains("35729"));
385    }
386
387    #[test]
388    fn inject_no_body_tag() {
389        let tmp = tempdir().expect("tempdir");
390        let path = tmp.path().join("page.html");
391        fs::write(&path, "<html><p>No body tag</p></html>").expect("write");
392
393        inject_livereload(&path, DEFAULT_PORT).expect("inject");
394
395        let result = fs::read_to_string(&path).expect("read");
396        assert!(result.contains(MARKER));
397    }
398
399    #[test]
400    fn skip_non_html_files() {
401        let tmp = tempdir().expect("tempdir");
402        fs::write(tmp.path().join("style.css"), "body{}").expect("write");
403        fs::write(tmp.path().join("data.json"), "{}").expect("write");
404        fs::write(tmp.path().join("readme.txt"), "hello").expect("write");
405
406        let files = collect_html_files(tmp.path()).expect("collect");
407        assert!(files.is_empty());
408    }
409
410    #[test]
411    fn empty_directory() {
412        let tmp = tempdir().expect("tempdir");
413        let files = collect_html_files(tmp.path()).expect("collect");
414        assert!(files.is_empty());
415    }
416
417    #[test]
418    fn nonexistent_directory() {
419        let ctx = PluginContext::new(
420            Path::new("c"),
421            Path::new("b"),
422            Path::new("/nonexistent_dir_ssg_test"),
423            Path::new("t"),
424        );
425        let plugin = LiveReloadPlugin::new();
426        assert!(plugin.on_serve(&ctx).is_ok());
427    }
428
429    #[test]
430    fn plugin_name() {
431        assert_eq!(LiveReloadPlugin::new().name(), "livereload");
432    }
433
434    #[test]
435    fn plugin_registration() {
436        use crate::plugin::PluginManager;
437        let mut pm = PluginManager::new();
438        pm.register(LiveReloadPlugin::new());
439        assert_eq!(pm.names(), vec!["livereload"]);
440    }
441
442    #[test]
443    fn with_port_constructor() {
444        let plugin = LiveReloadPlugin::with_port(8080);
445        assert_eq!(plugin.port(), 8080);
446    }
447
448    #[test]
449    fn default_port_value() {
450        let plugin = LiveReloadPlugin::new();
451        assert_eq!(plugin.port(), 35729);
452    }
453
454    #[test]
455    fn default_trait_impl() {
456        let plugin = LiveReloadPlugin::default();
457        assert_eq!(plugin.port(), DEFAULT_PORT);
458    }
459
460    #[test]
461    fn on_serve_injects_all_html_files() {
462        let tmp = tempdir().expect("tempdir");
463        fs::write(tmp.path().join("index.html"), make_html("<p>Home</p>"))
464            .expect("write index");
465        fs::write(tmp.path().join("about.html"), make_html("<p>About</p>"))
466            .expect("write about");
467        fs::write(tmp.path().join("style.css"), "body{}").expect("write css");
468
469        let ctx = PluginContext::new(
470            Path::new("content"),
471            Path::new("build"),
472            tmp.path(),
473            Path::new("templates"),
474        );
475        LiveReloadPlugin::new().on_serve(&ctx).expect("on_serve");
476
477        let index = fs::read_to_string(tmp.path().join("index.html"))
478            .expect("read index");
479        let about = fs::read_to_string(tmp.path().join("about.html"))
480            .expect("read about");
481        let css =
482            fs::read_to_string(tmp.path().join("style.css")).expect("read css");
483
484        assert!(index.contains(MARKER));
485        assert!(about.contains(MARKER));
486        assert!(!css.contains(MARKER));
487    }
488
489    #[test]
490    fn script_contains_reconnect_backoff() {
491        let script = livereload_script(DEFAULT_PORT);
492        assert!(script.contains("delay*2"));
493        assert!(script.contains("maxDelay"));
494        assert!(script.contains("10000"));
495    }
496
497    #[test]
498    fn script_contains_connecting_indicator() {
499        let script = livereload_script(DEFAULT_PORT);
500        assert!(script.contains("Connecting"));
501        assert!(script.contains("showIndicator"));
502        assert!(script.contains("hideIndicator"));
503        assert!(script.contains("bottom"));
504        assert!(script.contains("right"));
505    }
506
507    #[test]
508    fn livereload_custom_port() {
509        // Arrange
510        let port: u16 = 44444;
511
512        // Act
513        let script = livereload_script(port);
514
515        // Assert — custom port appears, default does not
516        assert!(script.contains("44444"));
517        assert!(!script.contains("35729"));
518    }
519
520    #[test]
521    fn livereload_plugin_no_html_files() {
522        // Arrange
523        let tmp = tempdir().expect("tempdir");
524        fs::write(tmp.path().join("style.css"), "body{}").expect("write");
525        fs::write(tmp.path().join("data.json"), "{}").expect("write");
526
527        let ctx = PluginContext::new(
528            Path::new("content"),
529            Path::new("build"),
530            tmp.path(),
531            Path::new("templates"),
532        );
533
534        // Act
535        let result = LiveReloadPlugin::new().on_serve(&ctx);
536
537        // Assert
538        assert!(result.is_ok());
539    }
540
541    #[test]
542    fn livereload_plugin_idempotent() {
543        // Arrange
544        let tmp = tempdir().expect("tempdir");
545        let html_path = tmp.path().join("page.html");
546        fs::write(&html_path, make_html("<p>Hello</p>")).expect("write");
547
548        let ctx = PluginContext::new(
549            Path::new("content"),
550            Path::new("build"),
551            tmp.path(),
552            Path::new("templates"),
553        );
554
555        // Act — run the full plugin twice
556        LiveReloadPlugin::new()
557            .on_serve(&ctx)
558            .expect("first on_serve");
559        let after_first = fs::read_to_string(&html_path).expect("read");
560
561        LiveReloadPlugin::new()
562            .on_serve(&ctx)
563            .expect("second on_serve");
564        let after_second = fs::read_to_string(&html_path).expect("read");
565
566        // Assert — content identical, no double injection
567        assert_eq!(after_first, after_second);
568        // The marker string appears in both the data attribute and the
569        // indicator id within a single injection, so count the script tags.
570        let script_count = after_second.matches("data-ssg-livereload").count();
571        assert_eq!(script_count, 1, "script tag should appear exactly once");
572    }
573
574    #[test]
575    fn livereload_script_contains_reconnect_logic() {
576        // Arrange & Act
577        let script = livereload_script(DEFAULT_PORT);
578
579        // Assert — script has exponential backoff reconnection
580        assert!(script.contains("delay*2"), "should double the delay");
581        assert!(script.contains("maxDelay"), "should cap the delay");
582        assert!(script.contains("setTimeout"), "should schedule reconnect");
583        assert!(script.contains("connect"), "should call connect again");
584    }
585
586    #[test]
587    fn livereload_plugin_nonexistent_dir() {
588        // Arrange
589        let ctx = PluginContext::new(
590            Path::new("content"),
591            Path::new("build"),
592            Path::new("/absolutely/nonexistent/directory/for/test"),
593            Path::new("templates"),
594        );
595
596        // Act
597        let result = LiveReloadPlugin::new().on_serve(&ctx);
598
599        // Assert — returns Ok, does not error on missing directory
600        assert!(result.is_ok());
601    }
602
603    #[test]
604    #[cfg(unix)]
605    fn on_serve_propagates_inject_read_failure() {
606        use std::os::unix::fs::PermissionsExt;
607
608        // An unreadable HTML file makes inject_livereload fail inside
609        // the on_serve loop, exercising its `?` propagation.
610        let tmp = tempdir().expect("tempdir");
611        let locked = tmp.path().join("locked.html");
612        fs::write(&locked, make_html("<p>Hi</p>")).expect("write");
613        fs::set_permissions(&locked, fs::Permissions::from_mode(0o000))
614            .expect("chmod");
615
616        let ctx = PluginContext::new(
617            Path::new("content"),
618            Path::new("build"),
619            tmp.path(),
620            Path::new("templates"),
621        );
622        let res = LiveReloadPlugin::new().on_serve(&ctx);
623
624        let _ = fs::set_permissions(&locked, fs::Permissions::from_mode(0o644));
625        assert!(res.is_err(), "unreadable HTML file must fail on_serve");
626    }
627
628    #[test]
629    #[cfg(unix)]
630    fn inject_fails_on_readonly_file() {
631        use std::os::unix::fs::PermissionsExt;
632
633        // Readable but not writable: the read succeeds, the marker is
634        // absent, and the final fs::write errors — exercising the write
635        // `?` branch of inject_livereload.
636        let tmp = tempdir().expect("tempdir");
637        let path = tmp.path().join("frozen.html");
638        fs::write(&path, make_html("<p>Hi</p>")).expect("write");
639        fs::set_permissions(&path, fs::Permissions::from_mode(0o444))
640            .expect("chmod");
641
642        let res = inject_livereload(&path, DEFAULT_PORT);
643
644        let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o644));
645        assert!(res.is_err(), "read-only HTML file must fail the write");
646    }
647
648    #[test]
649    fn test_script_contains_error_overlay() {
650        let script = livereload_script(DEFAULT_PORT);
651        assert!(
652            script.contains("showOverlay"),
653            "script must contain showOverlay function"
654        );
655        assert!(
656            script.contains("hideOverlay"),
657            "script must contain hideOverlay function"
658        );
659        assert!(
660            script.contains("ssg-error-overlay"),
661            "script must contain overlay element id"
662        );
663    }
664
665    #[test]
666    fn test_script_backward_compat() {
667        let script = livereload_script(DEFAULT_PORT);
668        assert!(
669            script.contains("'reload'"),
670            "script must still handle plain 'reload' messages"
671        );
672    }
673
674    #[test]
675    fn test_script_contains_css_reload() {
676        let script = livereload_script(DEFAULT_PORT);
677        assert!(
678            script.contains("css-reload"),
679            "script must contain css-reload handler"
680        );
681    }
682
683    #[test]
684    fn test_script_coordinates_with_view_transitions() {
685        // AC7: when the transitions client is loaded (issue #547),
686        // the reload handler must defer to its wrapper so structural
687        // HMR frames go through startViewTransition().
688        let script = livereload_script(DEFAULT_PORT);
689        assert!(
690            script.contains("__ssgTransitionsReload"),
691            "reload path must consult window.__ssgTransitionsReload"
692        );
693        assert!(
694            script.contains("location.reload()"),
695            "reload path must still call location.reload() as the fallback"
696        );
697    }
698
699    #[test]
700    fn test_script_contains_scroll_preservation() {
701        let script = livereload_script(DEFAULT_PORT);
702        assert!(
703            script.contains("ssg-scroll"),
704            "script must contain scroll preservation key"
705        );
706        assert!(
707            script.contains("sessionStorage"),
708            "script must use sessionStorage for scroll"
709        );
710    }
711
712    #[test]
713    fn test_css_reload_message() {
714        let msg = css_reload_message("styles/main.css");
715        let parsed: serde_json::Value =
716            serde_json::from_str(&msg).expect("valid JSON");
717        assert_eq!(parsed["type"], "css-reload");
718        assert_eq!(parsed["file"], "styles/main.css");
719    }
720
721    #[test]
722    fn test_inject_fails_on_nonexistent_file() {
723        let tmp = tempdir().unwrap();
724        let path = tmp.path().join("nonexistent.html");
725        let res = inject_livereload(&path, DEFAULT_PORT);
726        assert!(res.is_err());
727    }
728
729    #[test]
730    fn test_inject_fails_on_directory() {
731        let tmp = tempdir().unwrap();
732        let res = inject_livereload(tmp.path(), DEFAULT_PORT);
733        assert!(res.is_err());
734    }
735
736    #[test]
737    #[cfg(unix)]
738    fn test_on_serve_collect_html_files_error() {
739        use std::os::unix::fs::PermissionsExt;
740        let tmp = tempdir().unwrap();
741        let unreadable = tmp.path().join("unreadable");
742        fs::create_dir(&unreadable).unwrap();
743        fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000))
744            .unwrap();
745
746        let ctx = PluginContext::new(
747            Path::new("content"),
748            Path::new("build"),
749            &unreadable,
750            Path::new("templates"),
751        );
752        let res = LiveReloadPlugin::new().on_serve(&ctx);
753
754        let _ =
755            fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o755));
756        assert!(res.is_err());
757    }
758}