Forráskód Böngészése

report viewer: follow-SSE under a mount prefix (META.base), no double-append on (re)baseline; regression test

Alexander Korolev 1 hete
szülő
commit
381475a96c
4 módosított fájl, 71 hozzáadás és 6 törlés
  1. 1 0
      src/report/repo.rs
  2. 1 1
      src/report/serve.rs
  3. 55 1
      src/report/ui_tests.rs
  4. 14 4
      src/report/view.html

+ 1 - 0
src/report/repo.rs

@@ -174,6 +174,7 @@ pub(crate) async fn repo_route(
         "file": path,
         "content": clean,
         "links": serde_json::Value::Null,
+        "base": state.base,
     });
     let meta = serde_json::to_string(&meta)
         .unwrap_or_default()

+ 1 - 1
src/report/serve.rs

@@ -427,7 +427,7 @@ async fn report_file(
             } else {
                 Vec::new()
             };
-            let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean, "links": links });
+            let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean, "links": links, "base": state.base });
             let meta = serde_json::to_string(&meta)
                 .unwrap_or_default()
                 .replace('<', "\\u003c");

+ 55 - 1
src/report/ui_tests.rs

@@ -3,7 +3,7 @@
 // shared helpers (fixture) live in tests.rs.
 
 use super::tests::fixture;
-use super::{ReportRow, collect, html_page, markdown, serve};
+use super::{ReportRow, collect, html_page, html_page_at, markdown, serve};
 use crate::config::Config;
 use std::fs;
 
@@ -106,6 +106,60 @@ fn page_notif_diff_js() {
 /// router on an ephemeral port (fixture recipe dir), drives page.html with
 /// Playwright (deno, npm-free), drops a new report file into the watched
 /// dir and expects the badge + panel to react. Skipped without deno/browser.
+/// The viewer page (served under a mount prefix, e.g. the dashboard's
+/// `/recipes` panel) must carry that prefix in META so the follow-SSE URL
+/// builds against the right base — a root-absolute `/r/...` 404s under the
+/// prefix and the follow button looks dead. The prefixed SSE route must be
+/// reachable too.
+#[tokio::test]
+async fn viewer_meta_carries_mount_prefix() {
+    let dir = fixture();
+    let config: Config =
+        toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
+    let cfg = std::sync::Arc::new(config);
+    let rows = collect(&cfg, false);
+    let page = html_page_at(&rows, "/recipes");
+    let initial = serde_json::to_string(&rows).unwrap();
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+    let addr = listener.local_addr().unwrap();
+    let app = serve::router(cfg, page, initial, false, "/recipes", None);
+    let _h = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+    std::thread::sleep(std::time::Duration::from_millis(200));
+    let client = reqwest::Client::new();
+    let base = format!("http://{addr}");
+    let viewer = client
+        .get(format!(
+            "{base}/r/test-recipe-nvfp4/2026-01-04_roce-run.log"
+        ))
+        .send()
+        .await
+        .unwrap()
+        .text()
+        .await
+        .unwrap();
+    assert!(
+        viewer.contains("\"base\":\"/recipes\""),
+        "viewer META must carry the mount prefix"
+    );
+    assert!(viewer.contains("followbtn"));
+    // The SSE stream never ends — assert the route is live via its headers
+    // (send() resolves on the response head; drop the body).
+    let sse = client
+        .get(format!(
+            "{base}/r/test-recipe-nvfp4/2026-01-04_roce-run.log?follow=1"
+        ))
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(sse.status(), 200);
+    assert!(
+        sse.headers()
+            .get("content-type")
+            .is_some_and(|v| v.to_str().unwrap().contains("text/event-stream"))
+    );
+    fs::remove_dir_all(&dir).ok();
+}
+
 #[tokio::test]
 async fn report_playwright_checks() {
     if std::process::Command::new("deno")

+ 14 - 4
src/report/view.html

@@ -269,11 +269,21 @@ if (EXT === "log") {
       fbtn.textContent = "▶ follow"; fbtn.classList.remove("on");
       return;
     }
-    // open the SSE stream (the ?follow=1 endpoint streams deltas)
-    const url = "/r/" + encodeURIComponent(META.recipe) + "/" + encodeURIComponent(META.file) + "?follow=1";
+    // open the SSE stream (the ?follow=1 endpoint streams deltas). The page
+    // may be mounted under a prefix (the dashboard's /recipes panel) —
+    // META.base carries it ("" for the standalone report server).
+    const url = (META.base || "") + "/r/" + encodeURIComponent(META.recipe) + "/" + encodeURIComponent(META.file) + "?follow=1";
     es = new EventSource(url);
-    es.onmessage = (e) => { if (e.data) appendFollow(e.data); };
-    es.onerror = () => { /* the stream ends when the server drops it; stay put */ };
+    es.onmessage = (e) => {
+      const d = e.data;
+      if (!d) return;
+      // The (re)connect baseline is the full file content: when the incoming
+      // message extends the current buffer, it IS the new baseline (a fresh
+      // server stream re-sends the whole file) — replace, don't double it.
+      if (d.length > buf.length && d.startsWith(buf)) buf = d; else buf += d;
+      appendFollow("");
+    };
+    es.onerror = () => { /* the stream ends when the server drops it; EventSource reconnects */ };
     following = true;
     fbtn.textContent = "■ stop"; fbtn.classList.add("on");
   };