Skip to main content

bvr/
viewer_assets.rs

1//! Canonical viewer asset inventory for export bundles.
2//!
3//! All assets are embedded at compile time via `include_bytes!` and written
4//! deterministically during export.  The manifest is sorted by output path
5//! so that two exports of the same source produce identical file trees.
6
7use std::fs;
8use std::path::Path;
9
10use crate::Result;
11
12/// A single entry in the viewer asset inventory.
13#[derive(Debug, Clone, Copy)]
14pub struct AssetEntry {
15    /// Relative path inside the export bundle (e.g. `"vendor/d3.v7.min.js"`).
16    pub path: &'static str,
17    /// Raw bytes of the asset.
18    pub bytes: &'static [u8],
19    /// MIME type for HTTP serving.
20    pub content_type: &'static str,
21}
22
23// ---------------------------------------------------------------------------
24// Embedded assets – sorted alphabetically by output path.
25// ---------------------------------------------------------------------------
26
27/// Full viewer asset inventory, sorted by path for deterministic output.
28pub static ASSET_INVENTORY: &[AssetEntry] = &[
29    AssetEntry {
30        path: "charts.js",
31        bytes: include_bytes!("../viewer_assets/charts.js"),
32        content_type: "application/javascript; charset=utf-8",
33    },
34    AssetEntry {
35        path: "coi-serviceworker.js",
36        bytes: include_bytes!("../viewer_assets/coi-serviceworker.js"),
37        content_type: "application/javascript; charset=utf-8",
38    },
39    AssetEntry {
40        path: "graph-demo.html",
41        bytes: include_bytes!("../viewer_assets/graph-demo.html"),
42        content_type: "text/html; charset=utf-8",
43    },
44    AssetEntry {
45        path: "graph.js",
46        bytes: include_bytes!("../viewer_assets/graph.js"),
47        content_type: "application/javascript; charset=utf-8",
48    },
49    AssetEntry {
50        path: "hybrid_scorer.js",
51        bytes: include_bytes!("../viewer_assets/hybrid_scorer.js"),
52        content_type: "application/javascript; charset=utf-8",
53    },
54    AssetEntry {
55        path: "index.html",
56        bytes: include_bytes!("../viewer_assets/index.html"),
57        content_type: "text/html; charset=utf-8",
58    },
59    AssetEntry {
60        path: "styles.css",
61        bytes: include_bytes!("../viewer_assets/styles.css"),
62        content_type: "text/css; charset=utf-8",
63    },
64    AssetEntry {
65        path: "vendor/alpine-collapse.min.js",
66        bytes: include_bytes!("../viewer_assets/vendor/alpine-collapse.min.js"),
67        content_type: "application/javascript; charset=utf-8",
68    },
69    AssetEntry {
70        path: "vendor/alpine.min.js",
71        bytes: include_bytes!("../viewer_assets/vendor/alpine.min.js"),
72        content_type: "application/javascript; charset=utf-8",
73    },
74    AssetEntry {
75        path: "vendor/bv_graph.js",
76        bytes: include_bytes!("../viewer_assets/vendor/bv_graph.js"),
77        content_type: "application/javascript; charset=utf-8",
78    },
79    AssetEntry {
80        path: "vendor/bv_graph_bg.wasm",
81        bytes: include_bytes!("../viewer_assets/vendor/bv_graph_bg.wasm"),
82        content_type: "application/wasm",
83    },
84    AssetEntry {
85        path: "vendor/chart.umd.min.js",
86        bytes: include_bytes!("../viewer_assets/vendor/chart.umd.min.js"),
87        content_type: "application/javascript; charset=utf-8",
88    },
89    AssetEntry {
90        path: "vendor/d3.v7.min.js",
91        bytes: include_bytes!("../viewer_assets/vendor/d3.v7.min.js"),
92        content_type: "application/javascript; charset=utf-8",
93    },
94    AssetEntry {
95        path: "vendor/dompurify.min.js",
96        bytes: include_bytes!("../viewer_assets/vendor/dompurify.min.js"),
97        content_type: "application/javascript; charset=utf-8",
98    },
99    AssetEntry {
100        path: "vendor/force-graph.min.js",
101        bytes: include_bytes!("../viewer_assets/vendor/force-graph.min.js"),
102        content_type: "application/javascript; charset=utf-8",
103    },
104    AssetEntry {
105        path: "vendor/inter-variable.woff2",
106        bytes: include_bytes!("../viewer_assets/vendor/inter-variable.woff2"),
107        content_type: "font/woff2",
108    },
109    AssetEntry {
110        path: "vendor/jetbrains-mono-regular.woff2",
111        bytes: include_bytes!("../viewer_assets/vendor/jetbrains-mono-regular.woff2"),
112        content_type: "font/woff2",
113    },
114    AssetEntry {
115        path: "vendor/marked.min.js",
116        bytes: include_bytes!("../viewer_assets/vendor/marked.min.js"),
117        content_type: "application/javascript; charset=utf-8",
118    },
119    AssetEntry {
120        path: "vendor/mermaid.min.js",
121        bytes: include_bytes!("../viewer_assets/vendor/mermaid.min.js"),
122        content_type: "application/javascript; charset=utf-8",
123    },
124    AssetEntry {
125        path: "vendor/sql-wasm.js",
126        bytes: include_bytes!("../viewer_assets/vendor/sql-wasm.js"),
127        content_type: "application/javascript; charset=utf-8",
128    },
129    AssetEntry {
130        path: "vendor/sql-wasm.wasm",
131        bytes: include_bytes!("../viewer_assets/vendor/sql-wasm.wasm"),
132        content_type: "application/wasm",
133    },
134    AssetEntry {
135        path: "vendor/tailwindcss.js",
136        bytes: include_bytes!("../viewer_assets/vendor/tailwindcss.js"),
137        content_type: "application/javascript; charset=utf-8",
138    },
139    AssetEntry {
140        path: "viewer.js",
141        bytes: include_bytes!("../viewer_assets/viewer.js"),
142        content_type: "application/javascript; charset=utf-8",
143    },
144    AssetEntry {
145        path: "wasm_loader.js",
146        bytes: include_bytes!("../viewer_assets/wasm_loader.js"),
147        content_type: "application/javascript; charset=utf-8",
148    },
149];
150
151/// Number of assets in the canonical inventory.
152pub const ASSET_COUNT: usize = 24;
153
154/// Write all viewer assets to `output_dir`, creating subdirectories as needed.
155///
156/// Files are written in manifest order (sorted by path) for deterministic
157/// output.  Returns the list of relative paths written.
158pub fn write_viewer_assets(output_dir: &Path) -> Result<Vec<String>> {
159    let mut written = Vec::with_capacity(ASSET_INVENTORY.len());
160
161    for entry in ASSET_INVENTORY {
162        let dest = output_dir.join(entry.path);
163        if let Some(parent) = dest.parent() {
164            fs::create_dir_all(parent)?;
165        }
166        fs::write(&dest, entry.bytes)?;
167        written.push(entry.path.to_string());
168    }
169
170    Ok(written)
171}
172
173/// Look up an asset by its output path (for preview server).
174pub fn lookup_asset(path: &str) -> Option<&'static AssetEntry> {
175    ASSET_INVENTORY.iter().find(|e| e.path == path)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::collections::BTreeSet;
182
183    #[test]
184    fn inventory_count_matches_constant() {
185        assert_eq!(
186            ASSET_INVENTORY.len(),
187            ASSET_COUNT,
188            "ASSET_COUNT constant must match actual inventory length"
189        );
190    }
191
192    #[test]
193    fn inventory_paths_are_sorted() {
194        let paths: Vec<&str> = ASSET_INVENTORY.iter().map(|e| e.path).collect();
195        let mut sorted = paths.clone();
196        sorted.sort();
197        assert_eq!(paths, sorted, "inventory must be sorted by path");
198    }
199
200    #[test]
201    fn inventory_paths_are_unique() {
202        let paths: BTreeSet<&str> = ASSET_INVENTORY.iter().map(|e| e.path).collect();
203        assert_eq!(
204            paths.len(),
205            ASSET_INVENTORY.len(),
206            "inventory must not contain duplicate paths"
207        );
208    }
209
210    #[test]
211    fn inventory_has_no_empty_assets() {
212        for entry in ASSET_INVENTORY {
213            assert!(
214                !entry.bytes.is_empty(),
215                "asset {} must not be empty",
216                entry.path
217            );
218        }
219    }
220
221    #[test]
222    fn inventory_includes_index_html() {
223        assert!(
224            lookup_asset("index.html").is_some(),
225            "inventory must include index.html"
226        );
227    }
228
229    #[test]
230    fn inventory_includes_core_viewer_files() {
231        let expected = [
232            "index.html",
233            "viewer.js",
234            "styles.css",
235            "graph.js",
236            "charts.js",
237        ];
238        for path in expected {
239            assert!(
240                lookup_asset(path).is_some(),
241                "inventory must include {path}"
242            );
243        }
244    }
245
246    #[test]
247    fn inventory_includes_vendor_libraries() {
248        let expected_vendors = [
249            "vendor/alpine.min.js",
250            "vendor/d3.v7.min.js",
251            "vendor/force-graph.min.js",
252            "vendor/chart.umd.min.js",
253            "vendor/marked.min.js",
254            "vendor/mermaid.min.js",
255            "vendor/dompurify.min.js",
256            "vendor/sql-wasm.js",
257            "vendor/sql-wasm.wasm",
258            "vendor/tailwindcss.js",
259            "vendor/bv_graph.js",
260            "vendor/bv_graph_bg.wasm",
261        ];
262        for path in expected_vendors {
263            assert!(
264                lookup_asset(path).is_some(),
265                "inventory must include {path}"
266            );
267        }
268    }
269
270    #[test]
271    fn inventory_includes_fonts() {
272        let expected_fonts = [
273            "vendor/inter-variable.woff2",
274            "vendor/jetbrains-mono-regular.woff2",
275        ];
276        for path in expected_fonts {
277            assert!(
278                lookup_asset(path).is_some(),
279                "inventory must include {path}"
280            );
281        }
282    }
283
284    #[test]
285    fn write_viewer_assets_creates_all_files() {
286        let temp = tempfile::tempdir().expect("tempdir");
287        let written = write_viewer_assets(temp.path()).expect("write assets");
288
289        assert_eq!(written.len(), ASSET_COUNT);
290        for path in &written {
291            let file_path = temp.path().join(path);
292            assert!(file_path.is_file(), "asset must exist: {path}");
293            let content = std::fs::read(&file_path).expect("read file");
294            assert!(!content.is_empty(), "asset must not be empty: {path}");
295        }
296    }
297
298    #[test]
299    fn write_viewer_assets_is_deterministic() {
300        let temp1 = tempfile::tempdir().expect("tempdir1");
301        let temp2 = tempfile::tempdir().expect("tempdir2");
302        let written1 = write_viewer_assets(temp1.path()).expect("write1");
303        let written2 = write_viewer_assets(temp2.path()).expect("write2");
304
305        assert_eq!(written1, written2, "path lists must be identical");
306        for path in &written1 {
307            let bytes1 = std::fs::read(temp1.path().join(path)).expect("read1");
308            let bytes2 = std::fs::read(temp2.path().join(path)).expect("read2");
309            assert_eq!(bytes1, bytes2, "content must be identical for {path}");
310        }
311    }
312
313    #[test]
314    fn lookup_asset_returns_none_for_unknown() {
315        assert!(lookup_asset("nonexistent.txt").is_none());
316    }
317
318    #[test]
319    fn index_html_has_no_external_urls() {
320        let index = lookup_asset("index.html").expect("index.html exists");
321        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
322        assert!(
323            !html.contains("http://"),
324            "index.html must not reference http:// URLs"
325        );
326        assert!(
327            !html.contains("https://"),
328            "index.html must not reference https:// URLs"
329        );
330    }
331
332    #[test]
333    fn index_html_script_refs_resolve_to_inventory() {
334        let index = lookup_asset("index.html").expect("index.html");
335        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
336
337        // Extract src="..." and href="..." references to local asset files.
338        let mut missing = Vec::new();
339        for prefix in ["src=\"", "href=\""] {
340            let mut search_from = 0;
341            while let Some(start) = html[search_from..].find(prefix) {
342                let abs_start = search_from + start + prefix.len();
343                if let Some(end) = html[abs_start..].find('"') {
344                    let path = &html[abs_start..abs_start + end];
345                    search_from = abs_start + end + 1;
346                    // Skip fragment, data:, blob:, empty, or JS expression refs
347                    if path.is_empty()
348                        || path.starts_with('#')
349                        || path.starts_with("data:")
350                        || path.starts_with("blob:")
351                        || path.starts_with('\'')
352                        || path.contains('+')
353                    {
354                        continue;
355                    }
356                    if lookup_asset(path).is_none() {
357                        missing.push(path.to_string());
358                    }
359                } else {
360                    break;
361                }
362            }
363        }
364
365        assert!(
366            missing.is_empty(),
367            "index.html references assets not in inventory: {missing:?}"
368        );
369    }
370
371    #[test]
372    fn content_security_policy_is_self_contained() {
373        let index = lookup_asset("index.html").expect("index.html");
374        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
375        // CSP meta tag restricts sources to self for offline-safe deployment
376        assert!(html.contains("Content-Security-Policy"));
377    }
378
379    #[test]
380    fn csp_directives_enforce_offline_safety() {
381        let index = lookup_asset("index.html").expect("index.html");
382        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
383
384        // Extract CSP content attribute value
385        let csp_marker = "Content-Security-Policy";
386        let csp_pos = html.find(csp_marker).expect("CSP meta tag must exist");
387        let after_marker = &html[csp_pos..];
388        let content_start = after_marker
389            .find("content=\"")
390            .expect("CSP must have content attribute");
391        let content_value = &after_marker[content_start + 9..];
392        let content_end = content_value.find('"').expect("CSP content must close");
393        let csp = &content_value[..content_end];
394
395        // All directives must use 'self' as the base origin
396        let required_directives = [
397            "default-src",
398            "script-src",
399            "style-src",
400            "font-src",
401            "img-src",
402            "connect-src",
403            "worker-src",
404        ];
405        for directive in &required_directives {
406            assert!(
407                csp.contains(directive),
408                "CSP must include {directive} directive"
409            );
410        }
411
412        // connect-src must be self-only (no external fetch allowed for offline)
413        let connect_idx = csp.find("connect-src").unwrap();
414        let connect_val = &csp[connect_idx..];
415        let connect_end = connect_val.find(';').unwrap_or(connect_val.len());
416        let connect_directive = &connect_val[..connect_end];
417        assert!(
418            !connect_directive.contains("http:") && !connect_directive.contains("https:"),
419            "connect-src must not allow external URLs: {connect_directive}"
420        );
421
422        // font-src must be self-only (vendored fonts)
423        let font_idx = csp.find("font-src").unwrap();
424        let font_val = &csp[font_idx..];
425        let font_end = font_val.find(';').unwrap_or(font_val.len());
426        let font_directive = &font_val[..font_end];
427        assert!(
428            !font_directive.contains("http:") && !font_directive.contains("https:"),
429            "font-src must not allow external URLs: {font_directive}"
430        );
431
432        // worker-src must include blob: (for WASM workers)
433        let worker_idx = csp.find("worker-src").unwrap();
434        let worker_val = &csp[worker_idx..];
435        let worker_end = worker_val.find(';').unwrap_or(worker_val.len());
436        let worker_directive = &worker_val[..worker_end];
437        assert!(
438            worker_directive.contains("blob:"),
439            "worker-src must allow blob: for WASM workers: {worker_directive}"
440        );
441    }
442
443    #[test]
444    fn coi_service_worker_is_present_and_versioned() {
445        let sw = lookup_asset("coi-serviceworker.js").expect("coi-serviceworker.js");
446        let js = std::str::from_utf8(sw.bytes).expect("valid utf8");
447
448        // Must define a versioned cache name
449        assert!(
450            js.contains("CACHE_NAME") || js.contains("cache"),
451            "service worker must use a cache"
452        );
453
454        // Must inject cross-origin isolation headers
455        assert!(
456            js.contains("Cross-Origin-Embedder-Policy"),
457            "service worker must set COEP header"
458        );
459        assert!(
460            js.contains("Cross-Origin-Opener-Policy"),
461            "service worker must set COOP header"
462        );
463
464        // Must handle fetch events
465        assert!(
466            js.contains("fetch"),
467            "service worker must intercept fetch events"
468        );
469    }
470
471    #[test]
472    fn viewer_runtime_uses_vendored_sql_wasm_only() {
473        let viewer = lookup_asset("viewer.js").expect("viewer.js");
474        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
475
476        assert!(
477            js.contains("./vendor/sql-wasm.js"),
478            "viewer runtime must load vendored sql-wasm.js"
479        );
480        assert!(
481            js.contains("locateFile: file => `./vendor/${file}`"),
482            "viewer runtime must resolve sql-wasm assets from the local vendor directory"
483        );
484        assert!(
485            !js.contains("cdn.jsdelivr.net") && !js.contains("unpkg.com"),
486            "viewer runtime must not fall back to external CDNs"
487        );
488    }
489
490    #[test]
491    fn graph_runtime_cleans_up_keyboard_shortcuts_on_reinit() {
492        let graph = lookup_asset("graph.js").expect("graph.js");
493        let js = std::str::from_utf8(graph.bytes).expect("valid utf8");
494
495        assert!(
496            js.contains("let keyboardShortcutHandler = null;"),
497            "graph runtime must track a stable keyboard shortcut handler"
498        );
499        assert!(
500            js.contains("document.addEventListener('keydown', keyboardShortcutHandler);"),
501            "graph runtime must register keyboard shortcuts via the stable handler"
502        );
503        assert!(
504            js.contains("document.removeEventListener('keydown', keyboardShortcutHandler);"),
505            "graph runtime cleanup must remove keyboard shortcut listeners"
506        );
507    }
508
509    #[test]
510    fn graph_runtime_cleans_up_time_travel_styles_and_controls() {
511        let graph = lookup_asset("graph.js").expect("graph.js");
512        let js = std::str::from_utf8(graph.bytes).expect("valid utf8");
513
514        assert!(
515            js.contains("styleEl: null,"),
516            "graph runtime must track the injected time-travel style element"
517        );
518        assert!(
519            js.contains("timeTravelState.styleEl.remove();"),
520            "graph runtime must remove leaked time-travel style elements during rebuild/cleanup"
521        );
522        assert!(
523            js.contains("timeTravelState.controlsEl.remove();"),
524            "graph runtime cleanup must remove time-travel controls"
525        );
526    }
527
528    #[test]
529    fn viewer_runtime_avoids_duplicate_graph_detail_surfaces() {
530        let viewer = lookup_asset("viewer.js").expect("viewer.js");
531        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
532
533        assert!(
534            js.contains("if (this.view === 'graph') return;"),
535            "viewer runtime must keep the global graph click modal handler out of graph view"
536        );
537        assert!(
538            js.contains("this.graphDetailNode = node;"),
539            "viewer runtime must still route graph-view node clicks to the graph detail pane"
540        );
541    }
542
543    #[test]
544    fn viewer_runtime_binds_global_listeners_idempotently() {
545        let viewer = lookup_asset("viewer.js").expect("viewer.js");
546        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
547
548        assert!(
549            js.contains("globalListenersBound: false,"),
550            "viewer runtime must track whether global listeners are already bound"
551        );
552        assert!(
553            js.contains("if (!this.globalListenersBound) {"),
554            "viewer runtime must guard global listener binding inside init()"
555        );
556        assert!(
557            js.contains("this.globalListenersBound = true;"),
558            "viewer runtime must mark global listeners as bound"
559        );
560        assert!(
561            js.contains("hashChangeListenerBound: false,"),
562            "viewer runtime must track hashchange listener binding separately"
563        );
564    }
565
566    #[test]
567    fn viewer_runtime_clears_graph_detail_state_when_leaving_graph_view() {
568        let viewer = lookup_asset("viewer.js").expect("viewer.js");
569        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
570
571        assert!(
572            js.contains("case 'issue':\n          // Issue detail view\n          this.view = ISSUE_BACKDROP_VIEWS.has(route.query.get('from'))\n            ? route.query.get('from')\n            : 'issues';\n          this.graphDetailNode = null;"),
573            "viewer runtime must clear graph detail state before issue-detail transitions"
574        );
575        assert!(
576            js.contains("case 'issues':\n          this.view = 'issues';\n          this.selectedIssue = null;\n          this.graphDetailNode = null;"),
577            "viewer runtime must clear graph detail state before issue-list transitions"
578        );
579        assert!(
580            js.contains("this.view = 'insights';\n          this.selectedIssue = null;\n          this.graphDetailNode = null;"),
581            "viewer runtime must clear graph detail state when entering insights"
582        );
583        assert!(
584            js.contains("this.view = 'dashboard';\n          this.selectedIssue = null;\n          this.graphDetailNode = null;"),
585            "viewer runtime must clear graph detail state when returning to dashboard"
586        );
587    }
588
589    #[test]
590    fn viewer_runtime_tears_down_force_graph_on_route_exit_without_rebinding_bridge_listeners() {
591        let viewer = lookup_asset("viewer.js").expect("viewer.js");
592        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
593
594        assert!(
595            js.contains("graphBridgeListenersBound: false,"),
596            "viewer runtime must track graph bridge listener binding separately from graph readiness"
597        );
598        assert!(
599            js.contains("if (previousView === 'graph' && route.view !== 'graph') {\n        this.teardownForceGraph();\n      }"),
600            "viewer runtime must tear down the force graph when leaving graph view"
601        );
602        assert!(
603            js.contains("teardownForceGraph() {\n      if (this.forceGraphModule?.cleanup) {\n        this.forceGraphModule.cleanup();\n      }\n      this.forceGraphReady = false;"),
604            "viewer runtime must call graph cleanup and reset graph readiness on teardown"
605        );
606        assert!(
607            js.contains("if (!this.graphBridgeListenersBound) {\n          this.graphBridgeListenersBound = true;"),
608            "viewer runtime must avoid rebinding graph bridge listeners after teardown"
609        );
610    }
611
612    #[test]
613    fn graph_runtime_cancels_deferred_callbacks_during_cleanup() {
614        let graph = lookup_asset("graph.js").expect("graph.js");
615        let js = std::str::from_utf8(graph.bytes).expect("valid utf8");
616
617        assert!(
618            js.contains("const pendingTimeouts = new Set();"),
619            "graph runtime must track deferred timeout callbacks"
620        );
621        assert!(
622            js.contains("function scheduleTimeout(callback, delay) {"),
623            "graph runtime must route deferred callbacks through a tracked scheduler"
624        );
625        assert!(
626            js.contains("function clearScheduledTimeouts() {"),
627            "graph runtime must expose bulk timeout cleanup"
628        );
629        assert!(
630            js.contains("scheduleTimeout(() => {\n            store.graph?.zoomToFit(400, 50);\n        }, 500);"),
631            "graph runtime must guard delayed zoom-to-fit after teardown"
632        );
633        assert!(
634            js.contains("export function cleanup() {\n    clearScheduledTimeouts();"),
635            "graph cleanup must cancel deferred callbacks before tearing down graph state"
636        );
637        assert!(
638            js.contains("export function cleanup() {\n    clearScheduledTimeouts();\n    document.removeEventListener('mousemove', positionTooltip);"),
639            "graph cleanup must tear down tooltip listeners synchronously instead of scheduling fresh timeout work"
640        );
641    }
642
643    #[test]
644    fn viewer_runtime_uses_canonical_dashboard_route_when_closing_issue_modal() {
645        let viewer = lookup_asset("viewer.js").expect("viewer.js");
646        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
647
648        assert!(
649            js.contains("if (currentView === 'issues') {\n        navigateToIssues(this.filters, this.sort, this.searchQuery);\n      } else if (currentView === 'dashboard') {\n        navigateToDashboard();\n      } else {\n        navigate('/' + currentView);"),
650            "viewer runtime must use the canonical dashboard route when closing issue modal from dashboard view"
651        );
652    }
653
654    #[test]
655    fn viewer_runtime_preserves_issue_backdrop_view_for_routed_issue_flows() {
656        let viewer = lookup_asset("viewer.js").expect("viewer.js");
657        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
658
659        assert!(
660            js.contains("const ISSUE_BACKDROP_VIEWS = new Set(['dashboard', 'issues', 'insights', 'graph']);"),
661            "viewer runtime must define the allowed routed issue backdrop views"
662        );
663        assert!(
664            js.contains("function navigateToIssue(id, backdropView = null) {"),
665            "viewer runtime must allow routed issue navigation to carry backdrop context"
666        );
667        assert!(
668            js.contains("const from = validBackdrop && validBackdrop !== 'issues'\n    ? `?from=${encodeURIComponent(validBackdrop)}`\n    : '';"),
669            "viewer runtime must encode non-default backdrop views into the issue route"
670        );
671        assert!(
672            js.contains("this.view = ISSUE_BACKDROP_VIEWS.has(route.query.get('from'))\n            ? route.query.get('from')\n            : 'issues';"),
673            "viewer runtime must restore routed issue backdrop context from the route"
674        );
675        assert!(
676            js.contains("showIssue(id) {\n      navigateToIssue(id, this.view);\n    },"),
677            "viewer runtime must preserve the current view when opening routed issue detail"
678        );
679    }
680
681    #[test]
682    fn viewer_runtime_preserves_backdrop_for_mermaid_issue_navigation() {
683        let viewer = lookup_asset("viewer.js").expect("viewer.js");
684        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
685
686        assert!(
687            js.contains("const mermaidBackdropView = JSON.stringify(this.view);"),
688            "viewer runtime must capture the current backdrop view when wiring Mermaid issue links"
689        );
690        assert!(
691            js.contains("diagram += `  click ${nodeId} call window.beadsViewer.navigateToIssue(\"${id}\", ${mermaidBackdropView})\\n`;"),
692            "viewer runtime must preserve backdrop context for Mermaid issue-to-issue navigation"
693        );
694    }
695
696    #[test]
697    fn viewer_runtime_limits_issue_nav_list_to_issues_view() {
698        let viewer = lookup_asset("viewer.js").expect("viewer.js");
699        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
700
701        assert!(
702            js.contains("function issueNavListForView(view, issues) {\n  return view === 'issues' ? issues.map(issue => issue.id) : [];\n}"),
703            "viewer runtime must scope issue navigation lists to the issues view"
704        );
705        assert!(
706            js.contains("this.issueNavList = issueNavListForView(this.view, this.issues);"),
707            "viewer runtime must avoid seeding issue navigation from hidden issue-list data in other views"
708        );
709        assert!(
710            js.contains("if (!this.issueNavList.length) {\n        return;\n      }"),
711            "viewer runtime must not navigate through stale issue-list state when no valid issues-view navigation list exists"
712        );
713    }
714
715    #[test]
716    fn viewer_runtime_uses_backdrop_aware_issue_routes_for_modal_permalinks() {
717        let viewer = lookup_asset("viewer.js").expect("viewer.js");
718        let viewer_js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
719        let index = lookup_asset("index.html").expect("index.html");
720        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
721
722        assert!(
723            viewer_js.contains("function issueRouteFor(id, backdropView = null) {"),
724            "viewer runtime must centralize backdrop-aware issue route generation"
725        );
726        assert!(
727            viewer_js.contains("navigate(issueRouteFor(id, backdropView));"),
728            "viewer runtime must route imperative issue navigation through the shared issue route builder"
729        );
730        assert!(
731            html.contains(":href=\"'#' + issueRouteFor(selectedIssue.id, view)\""),
732            "issue modal permalink must preserve backdrop context through the shared issue route builder"
733        );
734    }
735
736    #[test]
737    fn viewer_runtime_preserves_routed_issue_urls_for_keyboard_dependency_navigation() {
738        let viewer = lookup_asset("viewer.js").expect("viewer.js");
739        let js = std::str::from_utf8(viewer.bytes).expect("valid utf8");
740
741        assert!(
742            js.contains("const route = parseRoute(window.location.hash);\n              if (route.view === 'issue') {\n                navigateToIssue(deps.blockedBy[0].id, this.view);\n              } else {\n                this.selectIssue(deps.blockedBy[0].id);\n              }"),
743            "viewer runtime must preserve routed issue URLs when keyboard navigation jumps to blocker issues"
744        );
745        assert!(
746            js.contains("const route = parseRoute(window.location.hash);\n              if (route.view === 'issue') {\n                navigateToIssue(deps.blocks[0].id, this.view);\n              } else {\n                this.selectIssue(deps.blocks[0].id);\n              }"),
747            "viewer runtime must preserve routed issue URLs when keyboard navigation jumps to dependent issues"
748        );
749    }
750
751    #[test]
752    fn viewer_runtime_only_handles_escape_when_issue_modal_is_visible() {
753        let index = lookup_asset("index.html").expect("index.html");
754        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
755
756        assert!(
757            html.contains("@keydown.escape.window=\"selectedIssue && closeIssue()\""),
758            "issue modal Escape handling must be gated on a visible selected issue instead of always binding globally"
759        );
760    }
761
762    #[test]
763    fn viewer_runtime_only_handles_escape_when_keyboard_help_is_visible() {
764        let index = lookup_asset("index.html").expect("index.html");
765        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
766
767        assert!(
768            html.contains(
769                "@keydown.escape.window=\"showKeyboardHelp && (showKeyboardHelp = false)\""
770            ),
771            "keyboard help Escape handling must be gated on the help modal actually being visible"
772        );
773    }
774
775    #[test]
776    fn viewer_runtime_only_polls_diagnostics_memory_stats_while_panel_is_visible() {
777        let index = lookup_asset("index.html").expect("index.html");
778        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
779
780        assert!(
781            html.contains("x-data=\"{ memStats: window.beadsViewer?.getWasmMemoryStats?.() || {}, memStatsPoll: null }\""),
782            "diagnostics memory widget must track its polling interval explicitly"
783        );
784        assert!(
785            html.contains("$watch('showDiagnostics', visible => {"),
786            "diagnostics memory widget must watch the diagnostics panel visibility"
787        );
788        assert!(
789            html.contains("if (visible && !memStatsPoll) {"),
790            "diagnostics memory widget must only start polling when the panel becomes visible"
791        );
792        assert!(
793            html.contains("} else if (!visible && memStatsPoll) {"),
794            "diagnostics memory widget must stop polling when the panel is hidden"
795        );
796    }
797
798    #[test]
799    fn viewer_runtime_uses_alpine_managed_resize_binding_for_graph_detail_pane() {
800        let index = lookup_asset("index.html").expect("index.html");
801        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
802
803        assert!(
804            html.contains("@resize.window=\"isMobile = window.innerWidth < 768\""),
805            "graph detail pane must use Alpine-managed resize binding instead of a raw window resize listener"
806        );
807        assert!(
808            !html.contains("x-init=\"window.addEventListener('resize', () => isMobile = window.innerWidth < 768)\""),
809            "graph detail pane must not install an untracked global resize listener from x-init"
810        );
811    }
812
813    #[test]
814    fn viewer_runtime_scopes_cycle_navigator_window_events_to_insights_view() {
815        let index = lookup_asset("index.html").expect("index.html");
816        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
817
818        assert!(
819            html.contains("$watch('view', newView => {"),
820            "cycle navigator must reset itself when the active view changes"
821        );
822        assert!(
823            html.contains("if (newView !== 'insights') {"),
824            "cycle navigator must clear stale active state when leaving insights"
825        );
826        assert!(
827            html.contains("@bv-graph:cycle-highlight-change.window=\"\n               if (view === 'insights') {"),
828            "cycle navigator must ignore graph highlight events while insights is hidden"
829        );
830        assert!(
831            html.contains("@bv-graph:cycle-navigator-reset.window=\"\n               if (view === 'insights') {"),
832            "cycle navigator reset handling must be scoped to the visible insights view"
833        );
834    }
835
836    #[test]
837    fn index_html_registers_service_worker() {
838        let index = lookup_asset("index.html").expect("index.html");
839        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
840
841        // Must register the COI service worker
842        assert!(
843            html.contains("coi-serviceworker.js"),
844            "index.html must reference the COI service worker"
845        );
846        assert!(
847            html.contains("serviceWorker.register"),
848            "index.html must register the service worker"
849        );
850
851        // Must have infinite-reload prevention (check for crossOriginIsolated)
852        assert!(
853            html.contains("crossOriginIsolated"),
854            "index.html must check crossOriginIsolated to prevent reload loops"
855        );
856    }
857
858    #[test]
859    fn script_loading_order_preserves_dependencies() {
860        let index = lookup_asset("index.html").expect("index.html");
861        let html = std::str::from_utf8(index.bytes).expect("valid utf8");
862
863        // Tailwind must load before body content (in <head>)
864        let tailwind_pos = html
865            .find("tailwindcss.js")
866            .expect("tailwind must be present");
867        let body_pos = html.find("<body").expect("body tag must exist");
868        assert!(
869            tailwind_pos < body_pos,
870            "Tailwind CSS must load in <head> before <body>"
871        );
872
873        // Alpine must use defer (executes after non-deferred scripts like DOMPurify)
874        let alpine_pos = html
875            .find("alpine.min.js")
876            .expect("Alpine.js must be present");
877        // Find the <script tag that contains alpine.min.js
878        let before_alpine = &html[..alpine_pos];
879        let script_start = before_alpine.rfind("<script").expect("alpine script tag");
880        let script_tag = &html[script_start..alpine_pos + 20];
881        assert!(
882            script_tag.contains("defer"),
883            "Alpine.js must use defer attribute for correct load ordering"
884        );
885        // DOMPurify must be present (non-deferred, executes before Alpine)
886        assert!(
887            html.contains("dompurify.min.js"),
888            "DOMPurify must be present"
889        );
890
891        // viewer.js (main app) must be last application script
892        let viewer_pos = html.find("viewer.js").expect("viewer.js must be present");
893        let charts_pos = html.find("charts.js").expect("charts.js must be present");
894        assert!(
895            viewer_pos > charts_pos,
896            "viewer.js must load after charts.js"
897        );
898
899        // WASM assets must have loaders
900        let wasm_loader_pos = html
901            .find("wasm_loader.js")
902            .expect("wasm_loader.js must be present");
903        assert!(
904            viewer_pos > wasm_loader_pos,
905            "viewer.js must load after WASM loader"
906        );
907    }
908
909    #[test]
910    fn wasm_runtime_assets_are_paired() {
911        // sql-wasm requires both .js and .wasm
912        assert!(
913            lookup_asset("vendor/sql-wasm.js").is_some(),
914            "sql-wasm.js must be in inventory"
915        );
916        assert!(
917            lookup_asset("vendor/sql-wasm.wasm").is_some(),
918            "sql-wasm.wasm must be in inventory"
919        );
920
921        // bv_graph requires both .js and .wasm
922        assert!(
923            lookup_asset("vendor/bv_graph.js").is_some(),
924            "bv_graph.js must be in inventory"
925        );
926        assert!(
927            lookup_asset("vendor/bv_graph_bg.wasm").is_some(),
928            "bv_graph_bg.wasm must be in inventory"
929        );
930    }
931
932    #[test]
933    fn asset_content_types_are_consistent() {
934        for entry in ASSET_INVENTORY {
935            let extension = std::path::Path::new(entry.path)
936                .extension()
937                .and_then(|ext| ext.to_str());
938            // Every entry must have a non-empty content type
939            assert!(
940                !entry.content_type.is_empty(),
941                "asset {} must have a content type",
942                entry.path
943            );
944            // Verify extension-to-type consistency
945            if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("woff2")) {
946                assert_eq!(
947                    entry.content_type, "font/woff2",
948                    "WOFF2 files must have font/woff2 content type: {}",
949                    entry.path
950                );
951            }
952            if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("css")) {
953                assert!(
954                    entry.content_type.starts_with("text/css"),
955                    "CSS files must have text/css content type: {} has {}",
956                    entry.path,
957                    entry.content_type
958                );
959            }
960            if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("html")) {
961                assert!(
962                    entry.content_type.starts_with("text/html"),
963                    "HTML files must have text/html content type: {} has {}",
964                    entry.path,
965                    entry.content_type
966                );
967            }
968        }
969    }
970}