1use std::fs;
8use std::path::Path;
9
10use crate::Result;
11
12#[derive(Debug, Clone, Copy)]
14pub struct AssetEntry {
15 pub path: &'static str,
17 pub bytes: &'static [u8],
19 pub content_type: &'static str,
21}
22
23pub 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
151pub const ASSET_COUNT: usize = 24;
153
154pub 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
173pub 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 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 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 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 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 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 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 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 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 assert!(
450 js.contains("CACHE_NAME") || js.contains("cache"),
451 "service worker must use a cache"
452 );
453
454 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 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 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 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 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 let alpine_pos = html
875 .find("alpine.min.js")
876 .expect("Alpine.js must be present");
877 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 assert!(
887 html.contains("dompurify.min.js"),
888 "DOMPurify must be present"
889 );
890
891 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 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 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 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 assert!(
940 !entry.content_type.is_empty(),
941 "asset {} must have a content type",
942 entry.path
943 );
944 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}