#![cfg(all(feature = "browser-tests", feature = "spa", feature = "test-support"))]
use std::fmt::Write as _;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use codelore_lib::Options;
use codelore_lib::analyses::code_health::{CodeHealthRow, run_code_health};
use codelore_lib::analyses::coupling::run_coupling;
use codelore_lib::analyses::hotspots::{HotspotRow, run_hotspots};
use codelore_lib::analyses::knowledge_islands::run_knowledge_islands;
use codelore_lib::analyses::summary::run_summary;
use codelore_lib::analyses::team_composition::run_team_composition;
use codelore_lib::facts::FactsDb;
use codelore_lib::output::spa::{SpaDashboard, write_spa};
use codelore_lib::repo::GixRepo;
use codelore_lib::test_support::{
coupling_repo, delivery_repo, differential_repo, permissive_coupling_opts,
};
use headless_chrome::Browser;
use headless_chrome::protocol::cdp::Emulation;
use headless_chrome::protocol::cdp::Page;
use headless_chrome::protocol::cdp::types::Event;
use codelore_lib::analyses::architecture_roles::ArchitectureRoleRow;
use codelore_lib::analyses::architecture_trend::ArchitectureTrendRow;
use codelore_lib::analyses::dashboard::{
CloneSummary, DailyCommit, ImportEdgeRow, KameiRiskRow, TrendPoint, XRayEntry,
};
use codelore_lib::analyses::effort_exposure::EffortExposureRow;
use codelore_lib::analyses::entity_ownership::EntityOwnershipRow;
use codelore_lib::analyses::factors::health_trend_factors;
use codelore_lib::analyses::health_trend::HealthTrendRow;
use codelore_lib::analyses::mi::MiRollup;
use codelore_lib::analyses::modularity_violations::ModularityViolationRow;
use codelore_lib::analyses::refactoring_targets::RefactoringTargetRow;
use codelore_lib::analyses::unstable_interface::UnstableInterfaceRow;
#[test]
#[allow(clippy::too_many_lines)] fn rendered_spa_boots_without_console_errors() {
let fixture = differential_repo::build();
let repo = GixRepo::open(fixture.dir.path()).expect("open fixture repo");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
let opts = Options {
repo_path: fixture.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
let entity_ownership = vec![
EntityOwnershipRow {
entity: "src/alpha/service.rs".to_string(),
author: "Alice".to_string(),
added: 200,
deleted: 40,
},
EntityOwnershipRow {
entity: "src/beta/handler.rs".to_string(),
author: "Bob".to_string(),
added: 150,
deleted: 30,
},
];
let clones = vec![
CloneSummary {
path: "src/alpha/service.rs".to_string(),
groups: 2,
},
CloneSummary {
path: "src/beta/handler.rs".to_string(),
groups: 1,
},
];
let modularity_violations = vec![ModularityViolationRow {
entity_a: "src/alpha/service.rs".to_string(),
entity_b: "src/beta/handler.rs".to_string(),
shared: 5,
degree: 0.55,
fisher_p: 0.02,
}];
let unstable_interface = vec![UnstableInterfaceRow {
path: "src/alpha/service.rs".to_string(),
fan_in: 4,
revisions: 12,
coupled_dependents: 3,
instability_score: 36.0,
}];
let architecture_roles = vec![
ArchitectureRoleRow {
path: "src/alpha/service.rs".to_string(),
role: "shared".to_string(),
vfi: 8,
vfo: 2,
in_cycle: false,
level: 1,
reach_pct: 25.0,
},
ArchitectureRoleRow {
path: "src/beta/handler.rs".to_string(),
role: "periphery".to_string(),
vfi: 1,
vfo: 0,
in_cycle: false,
level: 0,
reach_pct: 0.0,
},
];
let architecture_trend = vec![
ArchitectureTrendRow {
date: "2026-01-01".to_string(),
rev: "abc123456789".to_string(),
files: 8,
propagation_cost: 0.12,
cycle_count: 0,
largest_cycle: 0,
},
ArchitectureTrendRow {
date: "2026-02-01".to_string(),
rev: "def234567890".to_string(),
files: 10,
propagation_cost: 0.18,
cycle_count: 1,
largest_cycle: 3,
},
ArchitectureTrendRow {
date: "2026-03-01".to_string(),
rev: "fad345678901".to_string(),
files: 12,
propagation_cost: 0.22,
cycle_count: 2,
largest_cycle: 4,
},
];
let mi_rollup = Some(MiRollup {
low: 2,
moderate: 5,
high: 3,
unknown: 1,
});
let coupling_density = Some(0.08_f64);
let imports = vec![
ImportEdgeRow {
src_path: "src/alpha/service.rs".to_string(),
target_path: "src/beta/handler.rs".to_string(),
},
ImportEdgeRow {
src_path: "src/beta/handler.rs".to_string(),
target_path: "src/alpha/mod_0.rs".to_string(),
},
];
let xray = vec![
XRayEntry {
path: "src/alpha/service.rs".to_string(),
function: "run".to_string(),
cognitive: 5.0,
start_line: 10,
end_line: 40,
},
XRayEntry {
path: "src/beta/handler.rs".to_string(),
function: "handle".to_string(),
cognitive: 3.0,
start_line: 5,
end_line: 25,
},
];
let effort_exposure = vec![
EffortExposureRow {
band: "red".into(),
files: 2,
loc_share_pct: 18.0,
commit_share_pct: 35.0,
churn_share_pct: 30.0,
commit_share_ci_low: 0.22,
commit_share_ci_high: 0.50,
churn_share_improving_pct: None,
churn_share_degrading_pct: None,
},
EffortExposureRow {
band: "yellow".into(),
files: 3,
loc_share_pct: 32.0,
commit_share_pct: 25.0,
churn_share_pct: 28.0,
commit_share_ci_low: 0.16,
commit_share_ci_high: 0.36,
churn_share_improving_pct: None,
churn_share_degrading_pct: None,
},
EffortExposureRow {
band: "green".into(),
files: 5,
loc_share_pct: 50.0,
commit_share_pct: 40.0,
churn_share_pct: 42.0,
commit_share_ci_low: 0.28,
commit_share_ci_high: 0.54,
churn_share_improving_pct: None,
churn_share_degrading_pct: None,
},
];
let refactoring_targets = vec![
RefactoringTargetRow {
path: "src/refac/first_target.rs".to_string(),
priority: 9.9,
combined_risk: 42.0,
structural_risk: 0.8,
hotspot_score: 6.0,
revisions: 20,
loc: 30,
dominant_type: "complex-method".to_string(),
band: "red".to_string(),
manual_up_rank: 1,
},
RefactoringTargetRow {
path: "src/refac/second_target.rs".to_string(),
priority: 7.1,
combined_risk: 21.0,
structural_risk: 0.6,
hotspot_score: 4.0,
revisions: 12,
loc: 40,
dominant_type: "duplication".to_string(),
band: "yellow".to_string(),
manual_up_rank: 2,
},
];
let refactoring_target_paths = refactoring_targets
.iter()
.map(|r| r.path.clone())
.collect::<Vec<_>>();
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
entity_ownership,
clones,
modularity_violations,
unstable_interface,
architecture_roles,
architecture_trend,
mi_rollup,
coupling_density,
imports,
xray,
effort_exposure,
refactoring_targets,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Browser Smoke",
&fixture.dir.path().display().to_string(),
"2026-06-16 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let browser = match Browser::default() {
Ok(b) => b,
Err(e) => {
println!(
"spa_browser_test: skipping — could not launch Chrome ({e}). \
Install Chrome / Chromium and retry."
);
return;
}
};
let tab = browser.new_tab().expect("new tab");
tab.enable_log().expect("enable log");
tab.enable_runtime().expect("enable runtime");
let console_errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let exception_sink = Arc::clone(&console_errors);
let listener = move |event: &Event| {
if let Event::RuntimeExceptionThrown(thrown) = event {
let d = &thrown.params.exception_details;
let mut msg = format!("RuntimeException: {}", d.text);
if let Some(ex) = &d.exception
&& let Some(desc) = &ex.description
{
msg.push_str(" — ");
msg.push_str(desc);
}
let _ = write!(
msg,
" @ {}:{}",
d.url.clone().unwrap_or_else(|| "<inline>".into()),
d.line_number,
);
if let Some(trace) = &d.stack_trace {
let frames: Vec<String> = trace
.call_frames
.iter()
.take(5)
.map(|f| format!("{} @ {}:{}", f.function_name, f.url, f.line_number))
.collect();
if !frames.is_empty() {
msg.push_str("\n stack: ");
msg.push_str(&frames.join("\n "));
}
}
exception_sink.lock().expect("console mutex").push(msg);
}
};
tab.add_event_listener(Arc::new(listener))
.expect("add event listener");
let url = format!("file://{}", html_path.display());
tab.navigate_to(&url).expect("navigate");
tab.wait_until_navigated().expect("wait navigation");
std::thread::sleep(Duration::from_secs(2));
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"SPA produced {} browser-console error(s) during boot:\n{}",
errors.len(),
errors.join("\n "),
);
let kpi_html = tab
.find_element("#widget-kpi-tiles")
.expect("kpi tiles container")
.get_content()
.expect("kpi tiles html");
assert!(
kpi_html.contains("stat-value") || kpi_html.contains("kpi-value"),
"KPI tiles container had no rendered tile content; \
renderKpiTiles probably threw silently. HTML: {}",
&kpi_html[..kpi_html.len().min(500)]
);
let legend_html = tab
.find_element("#bivariate-legend")
.expect("bivariate legend container")
.get_content()
.expect("bivariate legend html");
assert!(
legend_html.len() > 50,
"bivariate legend container was empty; renderBivariateLegend \
probably did not run. HTML: {}",
&legend_html[..legend_html.len().min(200)]
);
let bivariate_is_default: bool = eval_json(
&tab,
"(() => { \
const bar = document.getElementById('hotspot-color-toggles'); \
if (!bar) return false; \
const sel = bar.querySelector('[aria-selected=\"true\"]'); \
return !!sel && sel.getAttribute('data-mode') === 'bivariate'; \
})()",
);
assert!(
bivariate_is_default,
"bivariate tab is not the default selected mode; the danger quadrant \
is hidden on initial dashboard load"
);
let selected_path: String = eval_json(
&tab,
"(function () { \
var tbody = document.getElementById('hotspot-tbody'); \
if (!tbody) return ''; \
var first = tbody.querySelector('tr[data-path]'); \
if (!first) return ''; \
var p = first.getAttribute('data-path'); \
window.__codeloreTestSelPath = p; \
window.Alpine.store('selection').set(p); \
return p; \
})()",
);
assert!(
!selected_path.is_empty(),
"no tr[data-path] row found in #hotspot-tbody; \
the linked-brushing assertion requires at least one rendered hotspot row"
);
std::thread::sleep(Duration::from_millis(100));
let row_highlighted: bool = eval_json(
&tab,
"(function () { \
var tbody = document.getElementById('hotspot-tbody'); \
if (!tbody) return false; \
var p = window.__codeloreTestSelPath; \
if (!p) return false; \
var row = tbody.querySelector('tr[data-path=\"' + p + '\"]'); \
return !!row && row.classList.contains('!bg-base-300'); \
})()",
);
assert!(
row_highlighted,
"setting selection via Alpine store did not highlight the matching \
hotspot-table row — cross-widget linked brushing is not wired"
);
let sankey_node: String = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-coupling-sankey-body'); \
if (!el || !window.echarts) return ''; \
var chart = window.echarts.getInstanceByDom(el); \
if (!chart) return ''; \
var opt = chart.getOption(); \
var series = opt && opt.series && opt.series[0]; \
var nodes = series && series.data; \
if (!nodes || !nodes.length) return ''; \
window.__codeloreSankeyTarget = nodes[0].name; \
window.Alpine.store('selection').clear(); \
return nodes[0].name; \
})()",
);
if !sankey_node.is_empty() {
std::thread::sleep(Duration::from_millis(100));
let _: bool = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-coupling-sankey-body'); \
var chart = el && window.echarts && window.echarts.getInstanceByDom(el); \
if (!chart) return false; \
window.__codeloreSankeyHi = null; \
var orig = chart.dispatchAction.bind(chart); \
chart.dispatchAction = function (p) { \
if (p && p.type === 'highlight') window.__codeloreSankeyHi = p.name || ''; \
return orig(p); \
}; \
window.Alpine.store('selection').set(window.__codeloreSankeyTarget); \
return true; \
})()",
);
std::thread::sleep(Duration::from_millis(100));
let captured: String = eval_json(
&tab,
"(function(){return window.__codeloreSankeyHi || '';})()",
);
assert_eq!(
captured, sankey_node,
"coupling-sankey selection listener did not dispatch a 'highlight' \
for the published node name — the cross-widget subscriber is not wired"
);
}
let bridged_path: String = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-coupling-sankey-body'); \
if (!el || !window.echarts) return ''; \
var chart = window.echarts.getInstanceByDom(el); \
if (!chart) return ''; \
var opt = chart.getOption(); \
var nodes = opt && opt.series && opt.series[0] && opt.series[0].data; \
var tbody = document.getElementById('hotspot-tbody'); \
if (!nodes || !tbody) return ''; \
for (var i = 0; i < nodes.length; i++) { \
var n = nodes[i].name; \
if (tbody.querySelector('tr[data-path=\"' + n + '\"]')) { \
window.__codeloreBridge = n; \
window.Alpine.store('selection').clear(); \
return n; \
} \
} \
return ''; \
})()",
);
if !bridged_path.is_empty() {
std::thread::sleep(Duration::from_millis(100));
let _: bool = eval_json(
&tab,
"(function(){ window._codeloreShowDetail(window.__codeloreBridge); return true; })()",
);
std::thread::sleep(Duration::from_millis(100));
let row_lit: bool = eval_json(
&tab,
&format!(
"(function () {{ var r = document.querySelector('#hotspot-tbody \
tr[data-path=\"{bridged_path}\"]'); return !!r && r.classList.contains('!bg-base-300'); }})()",
),
);
assert!(
row_lit,
"publishing a sankey node name via _codeloreShowDetail did not \
highlight the matching hotspot-table row — the publish → \
table-subscriber path is broken"
);
}
let brushed_count: i64 = eval_json(
&tab,
"(function () { \
var mount = document.getElementById('bivariate-legend'); \
if (!mount) return -1; \
var cells = mount.querySelectorAll('[data-biv-cell]'); \
if (!cells.length) return -1; \
var store = window.Alpine && window.Alpine.store && window.Alpine.store('brush'); \
if (!store) return -1; \
for (var i = 0; i < cells.length; i++) { \
store.clear(); \
cells[i].click(); \
if (store.paths && store.paths.length) { \
window.__codeloreBrushCellIdx = i; return store.paths.length; \
} \
} \
return 0; \
})()",
);
assert!(
brushed_count != -1,
"bivariate brush store / legend cells not wired (missing #bivariate-legend \
[data-biv-cell] cells or the `brush` Alpine store)"
);
if brushed_count > 0 {
std::thread::sleep(Duration::from_millis(100));
let rows_brushed: i64 = eval_json(
&tab,
"(function () { \
var t = document.getElementById('hotspot-tbody'); \
return t ? t.querySelectorAll('tr.hotspot-row-brushed').length : -1; \
})()",
);
assert!(
rows_brushed > 0,
"legend set-brush selected a non-empty quadrant but no hotspot-table row \
got `.hotspot-row-brushed` — the brush fan-out / table subscriber is not wired"
);
let _: bool = eval_json(
&tab,
"(function () { \
var cells = document.getElementById('bivariate-legend') \
.querySelectorAll('[data-biv-cell]'); \
cells[window.__codeloreBrushCellIdx].click(); return true; \
})()",
);
std::thread::sleep(Duration::from_millis(100));
let rows_after_clear: i64 = eval_json(
&tab,
"(function () { \
var t = document.getElementById('hotspot-tbody'); \
return t ? t.querySelectorAll('tr.hotspot-row-brushed').length : -1; \
})()",
);
assert_eq!(
rows_after_clear, 0,
"re-clicking the active legend cell did not clear the quadrant brush"
);
} else {
println!(
"spa_browser_test: bivariate brush step skipped — fixture has no populated \
health×activity quadrant (no code_health bands intersecting hotspots)"
);
}
let arch_trend_charted: bool = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-arch-trend-body'); \
return !!el && el.getAttribute('role') === 'img'; \
})()",
);
assert!(
arch_trend_charted,
"arch-trend container did not receive role=img; \
renderArchTrend bailed at the empty-data guard despite populated payload"
);
let mi_tile_present: bool = eval_json(
&tab,
"(function () { \
var kpi = document.getElementById('widget-kpi-tiles'); \
return !!kpi && kpi.textContent.includes('bottom quartile'); \
})()",
);
assert!(
mi_tile_present,
"MI band KPI sub-tile was absent from #widget-kpi-tiles; \
mi_rollup payload may not have reached renderKpiTiles"
);
let share_bars_mounted: bool = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-share-bars-body'); \
return !!el && el.innerHTML.trim().length > 0; \
})()",
);
assert!(
share_bars_mounted,
"share-bars widget body (#widget-share-bars-body) was empty after boot; \
renderShareBars may have thrown or the widget mount point is missing"
);
let tour_mounted: bool = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-guided-tour-body'); \
if (!el || el.innerHTML.trim().length === 0) return false; \
return el.querySelector('#tour-next') !== null; \
})()",
);
assert!(
tour_mounted,
"guided-tour widget body (#widget-guided-tour-body) was empty or missing \
the Start button after boot; renderGuidedTour may have thrown"
);
let active_mode = || -> String {
eval_json(
&tab,
"(function () { \
var bar = document.getElementById('hotspot-color-toggles'); \
if (!bar) return ''; \
var btns = bar.querySelectorAll('button[role=\"tab\"],button.toggle'); \
for (var i = 0; i < btns.length; i++) { \
if (btns[i].getAttribute('aria-selected') === 'true') \
return btns[i].getAttribute('data-mode') || ''; \
} \
return ''; \
})()",
)
};
let brush_count = || -> i64 {
eval_json(
&tab,
"(function () { \
var s = window.Alpine && window.Alpine.store && \
window.Alpine.store('brush'); \
if (!s || !s.paths) return 0; \
return s.paths.length; \
})()",
)
};
let click_next = || {
let _: bool = eval_json(
&tab,
"(function () { \
var btn = document.getElementById('tour-next'); \
if (btn) btn.click(); \
return !!btn; \
})()",
);
std::thread::sleep(Duration::from_millis(120));
};
click_next();
assert_eq!(
active_mode(),
"health",
"after Start the color-mode tab with data-mode='health' should be aria-selected; \
applyTourStep(0) did not sync the tab bar"
);
assert_eq!(
brush_count(),
0,
"step 0 should not set a brush (brushRefactoringTargets is false for step 0)"
);
click_next();
assert_eq!(
active_mode(),
"cognitive",
"after Next to step 1 the data-mode='cognitive' tab should be aria-selected"
);
click_next();
assert_eq!(
active_mode(),
"friction",
"after Next to step 2 the data-mode='friction' tab should be aria-selected"
);
click_next();
assert_eq!(
active_mode(),
"health",
"after Next to step 3 the data-mode='health' tab should be aria-selected"
);
let brushed_on_step3 = brush_count();
assert!(
brushed_on_step3 > 0,
"step 3 (brushRefactoringTargets=true) should brush at least one path; \
Alpine brush store is empty — bs.set(['targets','top10'],[...]) may not have fired"
);
let brushed_json: String = eval_json(
&tab,
"(function () { \
var s = window.Alpine && window.Alpine.store && window.Alpine.store('brush'); \
return JSON.stringify((s && s.paths) ? s.paths : []); \
})()",
);
let brushed_paths: Vec<String> = serde_json::from_str(&brushed_json).expect("brush paths json");
for want in &refactoring_target_paths {
assert!(
brushed_paths.iter().any(|p| p == want),
"step 3 brush should contain refactoring-target path {want:?} \
(sourced from data.refactoring_targets, not the hotspot proxy); got {brushed_paths:?}"
);
}
click_next();
assert_eq!(
active_mode(),
"bivariate",
"after Exit tour (Next on last step) the data-mode='bivariate' tab should be \
aria-selected; exitTour() did not restore the bivariate tab"
);
assert_eq!(
brush_count(),
0,
"after Exit tour the brush should be cleared; exitTour() called bs.clear() \
but the Alpine store still reports paths"
);
}
#[test]
fn rendered_spa_boots_without_scheduler_yield() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
write_smoke_spa(&html_path, "CodeLore No-Scheduler Boot");
let browser = match Browser::default() {
Ok(b) => b,
Err(e) => {
println!(
"spa_browser_test: skipping — could not launch Chrome ({e}). \
Install Chrome / Chromium and retry."
);
return;
}
};
let tab = browser.new_tab().expect("new tab");
tab.call_method(Page::AddScriptToEvaluateOnNewDocument {
source: "try { Object.defineProperty(window, 'scheduler', \
{ value: undefined, configurable: true, writable: true }); } \
catch (e) { try { delete window.scheduler; } catch (e2) {} }"
.to_string(),
world_name: None,
include_command_line_api: None,
run_immediately: None,
})
.expect("register scheduler-removal script");
let console_errors = attach_exception_sink(&tab);
let url = format!("file://{}", html_path.display());
tab.navigate_to(&url).expect("navigate");
tab.wait_until_navigated().expect("wait navigation");
std::thread::sleep(Duration::from_secs(2));
let scheduler_absent: bool = eval_json(&tab, "typeof scheduler === 'undefined'");
assert!(
scheduler_absent,
"`scheduler` was still defined after the override — the no-scheduler \
boot path was not exercised, so this test would pass vacuously"
);
let total: i64 = eval_json(
&tab,
"document.querySelectorAll('[id^=\"widget-\"][id$=\"-body\"]').length",
);
assert_eq!(
total, 23,
"expected 23 widget-body containers in the template; got {total} \
(template / WIDGETS drift — reconcile the count with the boot array)"
);
let rendered: i64 = eval_json(
&tab,
"(function () { \
var bodies = document.querySelectorAll('[id^=\"widget-\"][id$=\"-body\"]'); \
var n = 0; \
for (var i = 0; i < bodies.length; i++) { \
if (bodies[i].innerHTML.trim().length > 0) n++; \
} \
return n; \
})()",
);
assert_eq!(
rendered, total,
"only {rendered} of {total} widget bodies rendered with `scheduler.yield` \
absent — the cooperative boot loop died on its first yield instead of \
completing (a dead-zone read in the yieldToMain fallback)"
);
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"SPA produced {} browser error(s) during no-scheduler boot:\n {}",
errors.len(),
errors.join("\n "),
);
}
#[test]
#[allow(clippy::too_many_lines)] fn knowledge_islands_row_opens_and_closes_detail_drawer() {
let fixture = differential_repo::build();
let repo = GixRepo::open(fixture.dir.path()).expect("open fixture repo");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
let opts = Options {
repo_path: fixture.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
age_time_now: Some(time::macros::date!(2099 - 01 - 01)),
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
assert!(
!knowledge_islands.is_empty(),
"fixture produced no knowledge-island rows; the drawer-open \
assertions below would be vacuous. Adjust the anchor / fixture."
);
let ki_row_count = knowledge_islands.len();
println!("knowledge_islands_row_opens_and_closes_detail_drawer: {ki_row_count} KI rows");
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore KI Drawer Test",
&fixture.dir.path().display().to_string(),
"2026-06-16 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let row = tab
.wait_for_element("tr.ki-row")
.expect("at least one knowledge-islands row should render");
row.click().expect("click KI row");
std::thread::sleep(Duration::from_millis(300));
let drawer_open: bool = eval_json(
&tab,
"document.getElementById('file-detail-drawer').open === true",
);
assert!(drawer_open, "detail drawer did not open on KI row click");
let drawer_not_hidden: bool = eval_json(
&tab,
"!document.getElementById('file-detail-drawer').hasAttribute('hidden')",
);
assert!(
drawer_not_hidden,
"detail drawer kept the [hidden] attribute after open"
);
let drawer_displayed: bool = eval_json(
&tab,
"getComputedStyle(document.getElementById('file-detail-drawer')).display !== 'none'",
);
assert!(
drawer_displayed,
"detail drawer computed display:none after open (invisible popup)"
);
let title_len: i64 = eval_json(
&tab,
"document.getElementById('drawer-title').textContent.trim().length",
);
assert!(
title_len > 0,
"drawer title (clicked path) was empty; title_len={title_len}"
);
let body_len: i64 = eval_json(
&tab,
"document.getElementById('drawer-body').innerHTML.length",
);
assert!(body_len > 0, "drawer body was empty; body_len={body_len}");
let has_ki_section: bool = eval_json(
&tab,
"document.getElementById('drawer-body').textContent.includes('Knowledge island')",
);
assert!(
has_ki_section,
"drawer body had no 'Knowledge island' section for a KI-row click"
);
let close_btn = tab
.find_element("#drawer-close")
.expect("drawer close button should exist while drawer is open");
close_btn.click().expect("click drawer close");
std::thread::sleep(Duration::from_millis(300));
let drawer_closed: bool = eval_json(
&tab,
"(() => { const d = document.getElementById('file-detail-drawer'); \
return d.open === false && (d.hasAttribute('hidden') || \
getComputedStyle(d).display === 'none'); })()",
);
assert!(
drawer_closed,
"detail drawer did not close after clicking the × button"
);
}
#[test]
#[allow(clippy::too_many_lines)] fn detail_drawer_has_accessible_name_and_manages_focus() {
let fixture = differential_repo::build();
let repo = GixRepo::open(fixture.dir.path()).expect("open fixture repo");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
let opts = Options {
repo_path: fixture.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
age_time_now: Some(time::macros::date!(2099 - 01 - 01)),
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
assert!(
!knowledge_islands.is_empty(),
"fixture produced no knowledge-island rows; the focus assertions \
below would be vacuous. Adjust the anchor / fixture."
);
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Drawer Focus Test",
&fixture.dir.path().display().to_string(),
"2026-06-16 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
tab.wait_for_element("tr.ki-row")
.expect("at least one knowledge-islands row should render");
tab.evaluate(
"(() => { const r = document.querySelector('tr.ki-row'); \
r.setAttribute('data-focus-trigger-marker', '1'); r.focus(); \
return document.activeElement === r; })()",
false,
)
.expect("focus the KI row");
let row_focused_before: bool = eval_json(
&tab,
"document.activeElement === document.querySelector('tr[data-focus-trigger-marker]')",
);
assert!(
row_focused_before,
"could not focus the KI row before activation; test premise broken"
);
tab.evaluate(
"(() => { const r = document.querySelector('tr[data-focus-trigger-marker]'); \
r.dispatchEvent(new KeyboardEvent('keydown', \
{ key: 'Enter', bubbles: true })); })()",
false,
)
.expect("activate KI row via Enter");
std::thread::sleep(Duration::from_millis(300));
let drawer_open: bool = eval_json(
&tab,
"document.getElementById('file-detail-drawer').open === true",
);
assert!(
drawer_open,
"detail drawer did not open on KI row keyboard activation"
);
let drawer_named: bool = eval_json(
&tab,
"(() => { const d = document.getElementById('file-detail-drawer'); \
const ref = d.getAttribute('aria-labelledby'); \
if (!ref) return false; \
const labelEl = document.getElementById(ref); \
return !!labelEl && labelEl.textContent.trim().length > 0; })()",
);
assert!(
drawer_named,
"detail drawer has no resolvable accessible name \
(aria-labelledby -> non-empty title); screen readers announce \
it as an unnamed dialog"
);
let focus_inside_drawer: bool = eval_json(
&tab,
"(() => { const d = document.getElementById('file-detail-drawer'); \
const a = document.activeElement; \
return !!a && (a === d || d.contains(a)); })()",
);
assert!(
focus_inside_drawer,
"focus did not move into the drawer on open — keyboard / screen-reader \
users are stranded on the occluded trigger row"
);
let close_btn = tab
.find_element("#drawer-close")
.expect("drawer close button should exist while drawer is open");
close_btn.click().expect("click drawer close");
std::thread::sleep(Duration::from_millis(300));
let focus_restored: bool = eval_json(
&tab,
"document.activeElement === document.querySelector('tr[data-focus-trigger-marker]')",
);
assert!(
focus_restored,
"focus did not return to the trigger row after the drawer closed"
);
}
#[test]
fn team_composition_widget_renders_real_buckets_without_undefined() {
let fixture = delivery_repo::build();
let repo = GixRepo::open(fixture.dir.path()).expect("open fixture repo");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
let opts = Options {
repo_path: fixture.dir.path().to_path_buf(),
window_days: 90,
min_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let team_composition = run_team_composition(&db, &opts).expect("team-composition");
let real_author_count = team_composition
.iter()
.filter(|r| r.author != "__summary__")
.count();
assert!(
real_author_count >= 2,
"delivery_repo must produce ≥2 real author rows; got {real_author_count}"
);
let distinct_buckets: std::collections::HashSet<String> = team_composition
.iter()
.filter(|r| r.author != "__summary__")
.map(|r| r.bucket.clone())
.collect();
assert!(
distinct_buckets.len() >= 2,
"delivery_repo must span ≥2 distinct tenure buckets; got {distinct_buckets:?}"
);
assert!(
team_composition.iter().any(|r| r.author == "__summary__"),
"delivery_repo team-composition must include the __summary__ carrier row"
);
let dash = SpaDashboard {
hotspots,
summary,
code_health,
team_composition,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Team Composition Test",
&fixture.dir.path().display().to_string(),
"2026-06-16 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let widget_html = tab
.find_element("#widget-knowledge-surfaces-body")
.expect("knowledge-surfaces widget container")
.get_content()
.expect("widget html");
assert!(
!widget_html.contains("undefined"),
"team-composition widget rendered the literal string 'undefined' — \
the renderer is reading a field that does not exist on the row. HTML: {widget_html}"
);
assert!(
!widget_html.contains("__summary__"),
"team-composition widget rendered the __summary__ carrier row: {widget_html}"
);
for bucket in &distinct_buckets {
assert!(
widget_html.contains(bucket.as_str()),
"expected bucket name {bucket:?} in the rendered legend; HTML: {widget_html}"
);
}
let has_nonzero_segment: bool = eval_json(
&tab,
"(function () { \
var segs = document.querySelectorAll('#widget-knowledge-surfaces-body .team-bar-segment'); \
for (var i = 0; i < segs.length; i++) { \
var w = parseFloat(segs[i].style.width); \
if (!isNaN(w) && w > 0) return true; \
} \
return false; \
})()",
);
assert!(
has_nonzero_segment,
"no .team-bar-segment had a non-zero rendered width; the widget is \
still rendering zero-width bars"
);
}
#[allow(clippy::too_many_lines)]
fn write_smoke_spa(html_path: &std::path::Path, title: &str) {
let fixture = differential_repo::build();
let repo = GixRepo::open(fixture.dir.path()).expect("open fixture repo");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
let opts = Options {
repo_path: fixture.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
let trends: Vec<TrendPoint> = ["2026-01-01", "2026-02-01", "2026-03-01"]
.iter()
.enumerate()
.flat_map(|(i, month)| {
let step = f64::from(u32::try_from(i).unwrap_or(0));
[
("src/alpha/service.rs", 0.1f64.mul_add(-step, 0.9)),
("src/beta/handler.rs", 0.1f64.mul_add(step, 0.4)),
]
.into_iter()
.map(move |(path, score)| TrendPoint {
month: (*month).to_string(),
path: path.to_string(),
hotspot_score: score,
})
})
.collect();
let daily_commits: Vec<DailyCommit> = (1..=8)
.map(|d| DailyCommit {
date: format!("2026-01-{d:02}"),
count: d,
})
.collect();
let xray: Vec<XRayEntry> = (0..6)
.map(|i| XRayEntry {
path: if i % 2 == 0 {
"src/alpha/service.rs".to_string()
} else {
"src/beta/handler.rs".to_string()
},
function: format!("fn_{i}"),
cognitive: 3.0 + f64::from(i),
start_line: 1 + i * 10,
end_line: 9 + i * 10,
})
.collect();
let imports: Vec<ImportEdgeRow> = (0..4)
.map(|i| ImportEdgeRow {
src_path: format!("src/alpha/mod_{i}.rs"),
target_path: format!("src/beta/mod_{i}.rs"),
})
.collect();
let kamei_risk: Vec<KameiRiskRow> = (0..10)
.map(|i| KameiRiskRow {
rev: format!("{:040x}", i + 1),
date: format!("2026-02-{:02}", i + 1),
la: 20 + i,
ld: 5 + i,
nf: 1 + i % 4,
nd: 1 + i % 2,
ndev: 1 + i % 3,
nuc: i,
exp: 100 - i * 5,
entropy: 0.1 * f64::from(i),
fix: i % 3 == 0,
})
.collect();
let entity_ownership: Vec<EntityOwnershipRow> = [
("src/alpha/service.rs", "Alice", 200u64, 40u64),
("src/beta/handler.rs", "Bob", 150, 30),
("src/alpha/mod_0.rs", "Alice", 80, 10),
("src/beta/mod_0.rs", "Bob", 60, 5),
]
.iter()
.map(|&(entity, author, added, deleted)| EntityOwnershipRow {
entity: entity.to_string(),
author: author.to_string(),
added,
deleted,
})
.collect();
let clones: Vec<CloneSummary> = vec![
CloneSummary {
path: "src/alpha/service.rs".to_string(),
groups: 2,
},
CloneSummary {
path: "src/beta/handler.rs".to_string(),
groups: 1,
},
];
let modularity_violations: Vec<ModularityViolationRow> = vec![ModularityViolationRow {
entity_a: "src/alpha/service.rs".to_string(),
entity_b: "src/beta/handler.rs".to_string(),
shared: 5,
degree: 0.55,
fisher_p: 0.02,
}];
let unstable_interface: Vec<UnstableInterfaceRow> = vec![UnstableInterfaceRow {
path: "src/alpha/service.rs".to_string(),
fan_in: 4,
revisions: 12,
coupled_dependents: 3,
instability_score: 36.0,
}];
let architecture_roles: Vec<ArchitectureRoleRow> = vec![
ArchitectureRoleRow {
path: "src/alpha/service.rs".to_string(),
role: "shared".to_string(),
vfi: 8,
vfo: 2,
in_cycle: false,
level: 1,
reach_pct: 25.0,
},
ArchitectureRoleRow {
path: "src/beta/handler.rs".to_string(),
role: "periphery".to_string(),
vfi: 1,
vfo: 0,
in_cycle: false,
level: 0,
reach_pct: 0.0,
},
];
let architecture_trend: Vec<ArchitectureTrendRow> = vec![
ArchitectureTrendRow {
date: "2026-01-01".to_string(),
rev: "abc123456789".to_string(),
files: 8,
propagation_cost: 0.12,
cycle_count: 0,
largest_cycle: 0,
},
ArchitectureTrendRow {
date: "2026-02-01".to_string(),
rev: "def234567890".to_string(),
files: 10,
propagation_cost: 0.18,
cycle_count: 1,
largest_cycle: 3,
},
ArchitectureTrendRow {
date: "2026-03-01".to_string(),
rev: "fad345678901".to_string(),
files: 12,
propagation_cost: 0.22,
cycle_count: 2,
largest_cycle: 4,
},
];
let mi_rollup = Some(MiRollup {
low: 2,
moderate: 5,
high: 3,
unknown: 1,
});
let coupling_density = Some(0.08_f64);
let health_trend: Vec<HealthTrendRow> = vec![
HealthTrendRow {
date: "2026-01-01".to_string(),
rev: "abc123456789".to_string(),
files: 8,
arch_health: 72.0,
code_health: 68.0,
combined_health: 70.0,
arch_band: "green".to_string(),
code_band: "yellow".to_string(),
combined_band: "yellow".to_string(),
},
HealthTrendRow {
date: "2026-02-01".to_string(),
rev: "def234567890".to_string(),
files: 9,
arch_health: 70.0,
code_health: 65.0,
combined_health: 67.5,
arch_band: "green".to_string(),
code_band: "yellow".to_string(),
combined_band: "yellow".to_string(),
},
HealthTrendRow {
date: "2026-03-01".to_string(),
rev: "fad345678901".to_string(),
files: 10,
arch_health: 68.0,
code_health: 63.0,
combined_health: 65.5,
arch_band: "yellow".to_string(),
code_band: "yellow".to_string(),
combined_band: "yellow".to_string(),
},
HealthTrendRow {
date: "2026-04-01".to_string(),
rev: "bce456789012".to_string(),
files: 11,
arch_health: 65.0,
code_health: 60.0,
combined_health: 62.5,
arch_band: "yellow".to_string(),
code_band: "yellow".to_string(),
combined_band: "yellow".to_string(),
},
];
let factors = health_trend_factors(&health_trend);
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
trends,
daily_commits,
xray,
imports,
kamei_risk,
entity_ownership,
clones,
modularity_violations,
unstable_interface,
architecture_roles,
architecture_trend,
health_trend,
mi_rollup,
coupling_density,
factors,
effort_exposure: vec![
EffortExposureRow {
band: "red".into(),
files: 2,
loc_share_pct: 18.0,
commit_share_pct: 35.0,
churn_share_pct: 30.0,
commit_share_ci_low: 0.22,
commit_share_ci_high: 0.50,
churn_share_improving_pct: None,
churn_share_degrading_pct: None,
},
EffortExposureRow {
band: "green".into(),
files: 6,
loc_share_pct: 82.0,
commit_share_pct: 65.0,
churn_share_pct: 70.0,
commit_share_ci_low: 0.54,
commit_share_ci_high: 0.74,
churn_share_improving_pct: None,
churn_share_degrading_pct: None,
},
],
..SpaDashboard::default()
};
let mut f = std::fs::File::create(html_path).expect("create html");
write_spa(
&dash,
title,
&fixture.dir.path().display().to_string(),
"2026-06-16 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
}
fn boot_spa_tab(html_path: &std::path::Path) -> Option<(Browser, Arc<headless_chrome::Tab>)> {
let browser = match Browser::default() {
Ok(b) => b,
Err(e) => {
println!(
"spa_browser_test: skipping — could not launch Chrome ({e}). \
Install Chrome / Chromium and retry."
);
return None;
}
};
let tab = browser.new_tab().expect("new tab");
let url = format!("file://{}", html_path.display());
tab.navigate_to(&url).expect("navigate");
tab.wait_until_navigated().expect("wait navigation");
std::thread::sleep(Duration::from_secs(2));
Some((browser, tab))
}
fn eval_json<T: serde::de::DeserializeOwned>(tab: &headless_chrome::Tab, js: &str) -> T {
let value = tab
.evaluate(js, false)
.expect("evaluate js")
.value
.expect("js returned a value");
serde_json::from_value(value).expect("deserialize js result")
}
fn element_text(tab: &headless_chrome::Tab, id: &str) -> String {
eval_json(
tab,
&format!(
"(function () {{ \
var el = document.getElementById('{id}'); \
return el ? el.textContent.trim() : ''; \
}})()"
),
)
}
fn echarts_series_len(tab: &headless_chrome::Tab, host_id: &str) -> i64 {
eval_json(
tab,
&format!(
"(function () {{ \
var el = document.getElementById('{host_id}'); \
if (!el || !window.echarts) return -1; \
var chart = window.echarts.getInstanceByDom(el); \
if (!chart) return -1; \
var opt = chart.getOption(); \
var d = opt && opt.series && opt.series[0] && opt.series[0].data; \
return d ? d.length : -1; \
}})()"
),
)
}
fn attach_exception_sink(tab: &headless_chrome::Tab) -> Arc<Mutex<Vec<String>>> {
let console_errors: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&console_errors);
tab.enable_log().expect("enable log");
tab.enable_runtime().expect("enable runtime");
let listener = move |event: &Event| {
if let Event::RuntimeExceptionThrown(thrown) = event {
sink.lock()
.expect("console mutex")
.push(thrown.params.exception_details.text.clone());
}
};
tab.add_event_listener(Arc::new(listener))
.expect("add event listener");
console_errors
}
fn client_height(tab: &headless_chrome::Tab, id: &str) -> i64 {
eval_json(
tab,
&format!(
"(function () {{ \
var el = document.getElementById('{id}'); \
return el ? el.clientHeight : -1; \
}})()"
),
)
}
fn sweep_reaches_chart(tab: &headless_chrome::Tab, selector: &str, target_id: &str) -> bool {
eval_json(
tab,
&format!(
"(function () {{ \
if (!window.echarts) return false; \
var els = document.querySelectorAll('{selector}'); \
for (var i = 0; i < els.length; i++) {{ \
if (els[i].id === '{target_id}' \
&& window.echarts.getInstanceByDom(els[i])) return true; \
}} \
return false; \
}})()"
),
)
}
fn set_viewport(tab: &headless_chrome::Tab, w: u32, h: u32) {
tab.call_method(Emulation::SetDeviceMetricsOverride {
width: w,
height: h,
device_scale_factor: 1.0,
mobile: false,
scale: None,
screen_width: None,
screen_height: None,
position_x: None,
position_y: None,
dont_set_visible_size: None,
screen_orientation: None,
viewport: None,
display_feature: None,
device_posture: None,
})
.expect("set device metrics override");
}
fn bounding_rect(tab: &headless_chrome::Tab, selector: &str) -> (f64, f64, f64) {
let json: String = eval_json(
tab,
&format!(
"(function () {{ \
var el = document.querySelector('{selector}'); \
if (!el) return JSON.stringify([-1, -1, -1]); \
var r = el.getBoundingClientRect(); \
return JSON.stringify([r.top, r.left, r.width]); \
}})()"
),
);
serde_json::from_str(&json).expect("parse bounding rect JSON")
}
#[test]
fn tablist_arrow_keys_move_focus_and_selection() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
write_smoke_spa(&html_path, "CodeLore Tablist Keyboard Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let setup_ok: bool = eval_json(
&tab,
"(() => { const bar = document.getElementById('hotspot-color-toggles'); \
if (!bar) return false; \
const tabs = bar.querySelectorAll('[role=\"tab\"]'); \
if (tabs.length < 2) return false; \
tabs[0].setAttribute('data-kb-first', '1'); \
tabs[1].setAttribute('data-kb-second', '1'); \
tabs[0].focus(); \
return document.activeElement === tabs[0] && \
tabs[0].getAttribute('tabindex') === '0'; })()",
);
assert!(
setup_ok,
"could not focus the first tab with roving tabindex=0; \
either the tablist is missing or wireTablistArrows did not run"
);
tab.evaluate(
"(() => { const t = document.querySelector('[data-kb-first]'); \
t.dispatchEvent(new KeyboardEvent('keydown', \
{ key: 'ArrowRight', bubbles: true })); })()",
false,
)
.expect("dispatch ArrowRight");
std::thread::sleep(Duration::from_millis(200));
let focus_moved: bool = eval_json(
&tab,
"document.activeElement === document.querySelector('[data-kb-second]')",
);
assert!(
focus_moved,
"ArrowRight did not move focus to the next tab — tablist has no \
arrow-key navigation"
);
let selection_moved: bool = eval_json(
&tab,
"(() => { const first = document.querySelector('[data-kb-first]'); \
const second = document.querySelector('[data-kb-second]'); \
return second.getAttribute('aria-selected') === 'true' && \
first.getAttribute('aria-selected') === 'false' && \
second.getAttribute('tabindex') === '0' && \
first.getAttribute('tabindex') === '-1'; })()",
);
assert!(
selection_moved,
"ArrowRight moved focus but not the aria-selected / roving-tabindex \
state to the next tab"
);
}
#[test]
fn hotspot_tree_arrow_keys_move_focus_and_enter_opens_drawer() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-tree-keyboard.html");
write_smoke_spa(&html_path, "CodeLore Tree Keyboard Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
tab.evaluate(
"(() => { const menu = document.querySelector('[role=\"tree\"]'); \
const details = menu && menu.closest('details'); \
if (details) details.open = true; \
const items = menu ? menu.querySelectorAll('[role=\"treeitem\"]') : []; \
if (items[0]) items[0].setAttribute('data-kb-first', '1'); \
if (items[1]) items[1].setAttribute('data-kb-second', '1'); \
})()",
false,
)
.expect("open tree details + mark rows");
std::thread::sleep(Duration::from_millis(200));
let setup_ok: bool = eval_json(
&tab,
"(() => { const first = document.querySelector('[data-kb-first]'); \
const second = document.querySelector('[data-kb-second]'); \
if (!first || !second) return false; \
first.focus(); \
return document.activeElement === first && \
first.getAttribute('tabindex') === '0' && \
second.getAttribute('tabindex') === '-1'; })()",
);
assert!(
setup_ok,
"could not focus the first treeitem with roving tabindex=0; \
either the hotspot tree has fewer than 2 rows or the initial \
roving-tabindex binding is wrong"
);
tab.evaluate(
"document.querySelector('[data-kb-first]').dispatchEvent(\
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))",
false,
)
.expect("dispatch ArrowDown");
std::thread::sleep(Duration::from_millis(200));
let focus_moved: bool = eval_json(
&tab,
"(() => { const first = document.querySelector('[data-kb-first]'); \
const second = document.querySelector('[data-kb-second]'); \
return document.activeElement === second && \
second.getAttribute('tabindex') === '0' && \
first.getAttribute('tabindex') === '-1'; })()",
);
assert!(
focus_moved,
"ArrowDown did not move focus + roving tabindex to the next \
treeitem — the tree has no arrow-key navigation"
);
let drawer_still_closed: bool = eval_json(
&tab,
"document.getElementById('file-detail-drawer').open !== true",
);
assert!(
drawer_still_closed,
"ArrowDown must only move focus (WAI-ARIA treeview pattern) — it \
must not also activate the row and open the drawer"
);
tab.evaluate(
"document.querySelector('[data-kb-second]').dispatchEvent(\
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))",
false,
)
.expect("dispatch Enter");
std::thread::sleep(Duration::from_millis(300));
let drawer_opened: bool = eval_json(
&tab,
"document.getElementById('file-detail-drawer').open === true",
);
assert!(
drawer_opened,
"Enter on the focused treeitem did not open the file-detail drawer"
);
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"tree keyboard nav produced {} browser-console error(s):\n{}",
errors.len(),
errors.join("\n "),
);
}
fn synth_hotspot(path: &str, cognitive_health: f64, hotspot_score: f64) -> HotspotRow {
HotspotRow {
path: path.to_string(),
revisions: 5,
cognitive: 10.0,
cognitive_health,
hotspot_score,
mi: None,
mi_rank: None,
ai_pct: None,
hotspot_score_anchored: None,
}
}
fn synth_code_health(path: &str, band: &str, score: f64, structural_risk: f64) -> CodeHealthRow {
CodeHealthRow {
path: path.to_string(),
cognitive: 10.0,
score,
structural_risk,
percentile: 0.5,
band: band.to_string(),
corpus_percentile: None,
beyond_corpus: false,
corpus_percentile_ci_low: None,
corpus_percentile_ci_high: None,
}
}
#[test]
#[allow(clippy::too_many_lines)] fn hotspot_tree_badges_composite_code_health_band_not_cognitive_proxy() {
let fixture = differential_repo::build();
let repo = GixRepo::open(fixture.dir.path()).expect("open fixture repo");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
let opts = Options {
repo_path: fixture.dir.path().to_path_buf(),
min_revs: 1,
min_shared_revs: 1,
..Options::default()
};
db.ingest(&repo, &opts).expect("ingest fixture");
let summary = run_summary(&db, &opts).expect("summary");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
let hotspots = vec![
synth_hotspot("src/pkg/red_file.rs", 62.0, 9.0),
synth_hotspot("src/pkg/yellow_one.rs", 75.0, 8.0),
synth_hotspot("src/pkg/yellow_two.rs", 80.0, 7.0),
synth_hotspot("src/pkg/green_one.rs", 95.0, 6.0),
synth_hotspot("src/pkg/green_two.rs", 90.0, 5.0),
synth_hotspot("src/pkg/no_composite.rs", 70.0, 4.0),
];
let code_health = vec![
synth_code_health("src/pkg/red_file.rs", "red", 30.0, 0.80),
synth_code_health("src/pkg/yellow_one.rs", "yellow", 55.0, 0.40),
synth_code_health("src/pkg/yellow_two.rs", "yellow", 60.0, 0.35),
synth_code_health("src/pkg/green_one.rs", "green", 85.0, 0.10),
synth_code_health("src/pkg/green_two.rs", "green", 90.0, 0.05),
];
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-tree-badge.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Tree Badge Test",
&fixture.dir.path().display().to_string(),
"2026-06-16 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
tab.evaluate(
"(() => { const menu = document.querySelector('[role=\"tree\"]'); \
const d = menu && menu.closest('details'); if (d) d.open = true; })()",
false,
)
.expect("open tree details");
std::thread::sleep(Duration::from_millis(200));
let counts_json: String = eval_json(
&tab,
"(function () { \
var menu = document.querySelector('[role=\"tree\"]'); \
if (!menu) return JSON.stringify({ rows: -1 }); \
var badges = menu.querySelectorAll('[role=\"treeitem\"] span.badge'); \
var c = { error: 0, warning: 0, success: 0, ghost: 0, other: 0, rows: badges.length }; \
for (var i = 0; i < badges.length; i++) { \
var cl = badges[i].classList; \
if (cl.contains('badge-error')) c.error++; \
else if (cl.contains('badge-warning')) c.warning++; \
else if (cl.contains('badge-success')) c.success++; \
else if (cl.contains('badge-ghost')) c.ghost++; \
else c.other++; \
} \
return JSON.stringify(c); \
})()",
);
let counts: serde_json::Value = serde_json::from_str(&counts_json).expect("counts json");
assert_eq!(
counts["rows"], 6,
"expected 6 keyboard-list rows (top-50 by hotspot_score); counts={counts}"
);
assert_eq!(
counts["error"], 1,
"the composite band has exactly one red file, so the keyboard list must \
show one badge-error. The old cognitive_health proxy (bounded [60,100]) \
could NEVER emit badge-error; counts={counts}"
);
assert_eq!(
counts["warning"], 2,
"the composite band has two yellow files; counts={counts}"
);
assert_eq!(
counts["success"], 2,
"the composite band has two green files; counts={counts}"
);
assert_eq!(
counts["ghost"], 1,
"the hotspot with no composite code_health row must badge as 'no data' \
(badge-ghost); counts={counts}"
);
assert_eq!(
counts["other"], 0,
"every badge must be one of error/warning/success/ghost; counts={counts}"
);
let red_badge_text: String = eval_json(
&tab,
"(function () { \
var items = document.querySelectorAll('[role=\"treeitem\"]'); \
for (var i = 0; i < items.length; i++) { \
var pathEl = items[i].querySelector('.truncate'); \
if (pathEl && pathEl.textContent.indexOf('red_file.rs') >= 0) { \
var b = items[i].querySelector('span.badge'); \
return b ? b.textContent.trim() : ''; \
} \
} \
return ''; \
})()",
);
assert_eq!(
red_badge_text, "30",
"the red file's badge text must be the composite health score (30), not \
the cognitive_health proxy (62); got {red_badge_text:?}"
);
let badge_colors_json: String = eval_json(
&tab,
"(function () { \
var errorBadge = null; \
var items = document.querySelectorAll('[role=\"treeitem\"]'); \
for (var i = 0; i < items.length; i++) { \
var pathEl = items[i].querySelector('.truncate'); \
if (pathEl && pathEl.textContent.indexOf('red_file.rs') >= 0) { \
errorBadge = items[i].querySelector('span.badge'); \
break; \
} \
} \
if (!errorBadge) return JSON.stringify({ ok: false }); \
var probe = document.createElement('span'); \
probe.className = 'badge'; \
document.body.appendChild(probe); \
var errorBg = getComputedStyle(errorBadge).backgroundColor; \
var plainBg = getComputedStyle(probe).backgroundColor; \
probe.remove(); \
return JSON.stringify({ ok: true, errorBg: errorBg, plainBg: plainBg }); \
})()",
);
let badge_colors: serde_json::Value =
serde_json::from_str(&badge_colors_json).expect("badge colors json");
assert_eq!(
badge_colors["ok"], true,
"must locate the red file's badge-error element: {badge_colors}"
);
assert_ne!(
badge_colors["errorBg"], badge_colors["plainBg"],
"a badge-error badge must NOT render the same computed background as a \
plain, unmodified .badge — an unlayered legacy rule painting color \
regardless of the DaisyUI modifier class would make every badge the \
same colour: {badge_colors}"
);
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"tree badge test produced {} uncaught browser exception(s):\n {}",
errors.len(),
errors.join("\n "),
);
}
#[test]
fn broken_data_block_shows_alert_banner_and_no_widgets() {
let tmp = tempfile::tempdir().expect("tempdir");
let good = tmp.path().join("codelore-good.html");
write_smoke_spa(&good, "CodeLore Broken Data Test");
let html = std::fs::read_to_string(&good).expect("read good html");
let open = "id=\"codelore-data\">";
let start = html.find(open).expect("data block open tag") + open.len();
let close_rel = html[start..]
.find("</script>")
.expect("data block close tag");
let truncated = format!(
"{}\n{{\"partial\": true, \"data\":\n {}",
&html[..start],
&html[start + close_rel..],
);
let trunc_path = tmp.path().join("codelore-truncated.html");
std::fs::write(&trunc_path, &truncated).expect("write truncated html");
assert_banner_and_no_widgets(&trunc_path, "truncated or corrupt");
let missing = html.replace("id=\"codelore-data\"", "id=\"codelore-data-removed\"");
let missing_path = tmp.path().join("codelore-missing.html");
std::fs::write(&missing_path, &missing).expect("write missing html");
assert_banner_and_no_widgets(&missing_path, "missing");
}
fn assert_banner_and_no_widgets(html_path: &std::path::Path, condition_phrase: &str) {
let Some((_browser, tab)) = boot_spa_tab(html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
let banner_present: bool = eval_json(
&tab,
"!!document.querySelector('main [role=\"alert\"].codelore-boot-error')",
);
assert!(
banner_present,
"no role=alert boot-error banner inside <main> for a broken data block — \
the dashboard rendered empty chrome with no visible failure"
);
let banner_text: String = eval_json(
&tab,
"(function () { var b = document.querySelector('main [role=\"alert\"]'); \
return b ? b.textContent : ''; })()",
);
assert!(
banner_text.contains(condition_phrase),
"boot-error banner did not name the condition ({condition_phrase:?}); \
text={banner_text:?}"
);
assert!(
banner_text.contains("Regenerate") && banner_text.contains("--format spa"),
"boot-error banner did not state the regenerate remedy; text={banner_text:?}"
);
let widget_bodies: i64 = eval_json(
&tab,
"document.querySelectorAll('[id^=\"widget-\"][id$=\"-body\"]').length",
);
assert_eq!(
widget_bodies, 0,
"broken data block still left {widget_bodies} widget-body container(s) in \
the DOM — the banner must REPLACE main's content, not sit above a \
chromed-but-empty dashboard"
);
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"boot-error path produced {} uncaught browser exception(s):\n {}",
errors.len(),
errors.join("\n "),
);
}
#[test]
fn nav_chip_scrolls_section_into_view_without_hash() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-nav-chip.html");
write_smoke_spa(&html_path, "CodeLore Nav Chip Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
let hash_before: String = eval_json(&tab, "location.hash");
tab.evaluate(
"document.querySelector('.dash-nav-chip[data-target=\"group-architecture\"]').click()",
false,
)
.expect("click architecture chip");
let mut rect_top = f64::MAX;
let mut viewport_h = 0.0f64;
for _ in 0..40 {
std::thread::sleep(Duration::from_millis(100));
rect_top = eval_json(
&tab,
"document.getElementById('group-architecture').getBoundingClientRect().top",
);
viewport_h = eval_json(&tab, "window.innerHeight");
if rect_top >= 0.0 && rect_top < viewport_h {
break;
}
}
assert!(
rect_top >= 0.0 && rect_top < viewport_h,
"group-architecture's top ({rect_top}) never settled within the \
viewport (height {viewport_h})"
);
let scroll_y: f64 = eval_json(&tab, "window.scrollY");
assert!(scroll_y > 0.0, "clicking the chip did not scroll the page");
let hash_after: String = eval_json(&tab, "location.hash");
assert_eq!(
hash_before, hash_after,
"chip click must never mutate location.hash — the SPA owns it as \
its state serializer"
);
let arch_active: bool = eval_json(
&tab,
"document.querySelector('.dash-nav-chip[data-target=\"group-architecture\"]')\
.classList.contains('dash-active')",
);
let overview_active: bool = eval_json(
&tab,
"document.querySelector('.dash-nav-chip[data-target=\"group-overview\"]')\
.classList.contains('dash-active')",
);
assert!(arch_active, "clicked chip did not gain the active class");
assert!(!overview_active, "overview chip is still marked active");
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"nav chip click produced {} browser-console error(s):\n{}",
errors.len(),
errors.join("\n "),
);
}
#[test]
fn factor_tile_is_a_keyboard_activatable_jump_link() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-factor-tile.html");
write_smoke_spa(&html_path, "CodeLore Factor Tile Jump Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
let tile_selector =
"document.querySelector('.factor-tile[data-target=\"group-architecture\"]')";
let tile_role: String = eval_json(
&tab,
&format!("({tile_selector}).getAttribute('role') || ''"),
);
assert_eq!(tile_role, "link", "factor tile must carry role=\"link\"");
let cursor: String = eval_json(&tab, &format!("getComputedStyle({tile_selector}).cursor"));
assert_eq!(cursor, "pointer", "factor tile must show a pointer cursor");
let hash_before: String = eval_json(&tab, "location.hash");
tab.evaluate(
&format!(
"(() => {{ const t = {tile_selector}; t.focus(); \
t.dispatchEvent(new KeyboardEvent('keydown', {{ key: 'Enter', bubbles: true }})); \
}})()"
),
false,
)
.expect("dispatch Enter on the factor tile");
let mut rect_top = f64::MAX;
let mut viewport_h = 0.0f64;
for _ in 0..40 {
std::thread::sleep(Duration::from_millis(100));
rect_top = eval_json(
&tab,
"document.getElementById('group-architecture').getBoundingClientRect().top",
);
viewport_h = eval_json(&tab, "window.innerHeight");
if rect_top >= 0.0 && rect_top < viewport_h {
break;
}
}
assert!(
rect_top >= 0.0 && rect_top < viewport_h,
"Enter on the Architecture factor tile never scrolled group-architecture \
into view (top {rect_top}, viewport {viewport_h})"
);
let hash_after: String = eval_json(&tab, "location.hash");
assert_eq!(
hash_before, hash_after,
"factor-tile Enter activation must never mutate location.hash"
);
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"factor tile keyboard activation produced {} browser-console error(s):\n{}",
errors.len(),
errors.join("\n "),
);
}
#[test]
fn chart_containers_expose_text_alternative() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
write_smoke_spa(&html_path, "CodeLore Chart A11y Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let labelled_count: i64 = eval_json(
&tab,
"Array.from(document.querySelectorAll('.widget-body[role=\"img\"]')) \
.filter(el => (el.getAttribute('aria-label') || '').trim().length > 0) \
.length",
);
assert!(
labelled_count >= 8,
"expected >=8 chart containers with role=img + non-empty aria-label, \
found {labelled_count}; chart renderers are not stamping text alternatives"
);
}
#[test]
fn hotspot_table_summary_is_a_live_region() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
write_smoke_spa(&html_path, "CodeLore Live-Region Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let is_live_region: bool = eval_json(
&tab,
"(() => { const el = document.getElementById('hotspot-table-summary'); \
return !!el && el.getAttribute('aria-live') === 'polite' && \
el.getAttribute('role') === 'status'; })()",
);
assert!(
is_live_region,
"hotspot-table summary is not a polite live region; filter-count \
updates are silent to screen readers"
);
}
#[test]
fn detail_drawer_never_renders_empty_for_a_pathless_row() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
write_smoke_spa(&html_path, "CodeLore Empty-Path Drawer Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
tab.evaluate("window._codeloreShowDetail('')", false)
.expect("invoke detail with empty path");
std::thread::sleep(Duration::from_millis(300));
let title_nonempty: bool = eval_json(
&tab,
"document.getElementById('drawer-title').textContent.trim().length > 0",
);
assert!(
title_nonempty,
"drawer title is blank for a pathless row — the popup renders empty"
);
let body_nonempty: bool = eval_json(
&tab,
"document.getElementById('drawer-body').textContent.trim().length > 0",
);
assert!(
body_nonempty,
"drawer body is blank for a pathless row — the popup renders empty"
);
}
#[test]
fn detail_drawer_content_is_opaque_when_open() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore.html");
write_smoke_spa(&html_path, "CodeLore Drawer Visibility Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
tab.evaluate("window._codeloreShowDetail('any/path')", false)
.expect("open the drawer");
std::thread::sleep(Duration::from_millis(400));
let box_opaque: bool = eval_json(
&tab,
"(() => { const b = document.querySelector('#file-detail-drawer .modal-box'); \
return !!b && getComputedStyle(b).opacity === '1'; })()",
);
assert!(
box_opaque,
"drawer .modal-box is not opaque (opacity != 1) — the populated \
content is invisible, which reads as a blank popup"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn sankey_module_depth_highlights_mapped_node() {
let fixture = coupling_repo::build();
let opts = permissive_coupling_opts(fixture.dir.path().to_path_buf());
let repo = GixRepo::open(fixture.dir.path()).expect("open coupling fixture");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
db.ingest(&repo, &opts).expect("ingest coupling fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-coupling.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Module-Depth Coupling Test",
&fixture.dir.path().display().to_string(),
"2026-06-20 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let _: bool = eval_json(
&tab,
"(function () { \
var L = window.Alpine && window.Alpine.store && window.Alpine.store('layout'); \
if (!L) return false; \
L.sankeyDepth = 2; \
return true; \
})()",
);
let mut prefix_node = String::new();
for _ in 0..30 {
std::thread::sleep(Duration::from_millis(100));
prefix_node = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-coupling-sankey-body'); \
if (!el || !window.echarts) return ''; \
var chart = window.echarts.getInstanceByDom(el); \
if (!chart) return ''; \
var opt = chart.getOption(); \
var nodes = opt && opt.series && opt.series[0] && opt.series[0].data; \
if (!nodes || !nodes.length) return ''; \
var tbody = document.getElementById('hotspot-tbody'); \
if (!tbody) return ''; \
function modPrefix(p) { \
var parts = (p || '').split('/'); \
if (parts.length <= 2) { \
var ls = (p || '').lastIndexOf('/'); \
return ls < 0 ? (p || '') : p.slice(0, ls); \
} \
return parts.slice(0, 2).join('/'); \
} \
var rows = tbody.querySelectorAll('tr[data-path]'); \
for (var r = 0; r < rows.length; r++) { \
var p = rows[r].getAttribute('data-path'); \
var pref = modPrefix(p); \
for (var n = 0; n < nodes.length; n++) { \
if (nodes[n].name === pref) { \
window.__codeloreModPath2 = p; \
window.__codeloreModPrefix2 = pref; \
return pref; \
} \
} \
} \
return ''; \
})()",
);
if !prefix_node.is_empty() {
break;
}
}
assert!(
!prefix_node.is_empty(),
"depth-2 sankey has no node matching any hotspot-table row's module prefix; \
the coupling_repo fixture must produce cross-module co-changes at depth 2"
);
let _: bool = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-coupling-sankey-body'); \
var chart = el && window.echarts && window.echarts.getInstanceByDom(el); \
if (!chart) return false; \
window.__codeloreModHi2 = null; \
var orig = chart.dispatchAction.bind(chart); \
chart.dispatchAction = function (pp) { \
if (pp && pp.type === 'highlight') window.__codeloreModHi2 = pp.name || ''; \
return orig(pp); \
}; \
window.Alpine.store('selection').clear(); \
return true; \
})()",
);
std::thread::sleep(Duration::from_millis(100));
let _: bool = eval_json(
&tab,
"(function () { \
window.Alpine.store('selection').set(window.__codeloreModPath2); \
return true; \
})()",
);
std::thread::sleep(Duration::from_millis(100));
let captured: String = eval_json(
&tab,
"(function () { return window.__codeloreModHi2 || ''; })()",
);
assert_eq!(
captured, prefix_node,
"in module-depth view the coupling subscriber highlighted '{captured}' but \
expected module prefix '{prefix_node}' — the modulePathSeg mapping is not \
applied to the incoming selection path",
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn detail_drawer_groups_sections_into_tabs() {
let fixture = coupling_repo::build();
let opts = permissive_coupling_opts(fixture.dir.path().to_path_buf());
let repo = GixRepo::open(fixture.dir.path()).expect("open coupling fixture");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
db.ingest(&repo, &opts).expect("ingest coupling fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-drawer.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Drawer Tabs Test",
&fixture.dir.path().display().to_string(),
"2026-06-20 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let opened: bool = eval_json(
&tab,
"(function () { \
var tbody = document.getElementById('hotspot-tbody'); \
if (!tbody) return false; \
var row = tbody.querySelector('tr[data-path]'); \
if (!row) return false; \
window._codeloreShowDetail(row.getAttribute('data-path')); \
return true; \
})()",
);
assert!(opened, "no hotspot-table row to open the drawer from");
std::thread::sleep(Duration::from_millis(100));
let tab_count: i64 = eval_json(
&tab,
"(function () { \
var b = document.getElementById('drawer-body'); \
return b ? b.querySelectorAll('[role=\"tab\"]').length : -1; \
})()",
);
assert_eq!(tab_count, 3, "drawer should expose exactly 3 tabs");
let overview_default: bool = eval_json(
&tab,
"(function () { \
var ov = document.getElementById('drawer-panel-overview'); \
var cp = document.getElementById('drawer-panel-coupling'); \
return !!ov && !!cp && !ov.classList.contains('hidden') \
&& cp.classList.contains('hidden'); \
})()",
);
assert!(
overview_default,
"Overview panel must be visible and Coupling hidden by default"
);
let switched: bool = eval_json(
&tab,
"(function () { \
var t = document.getElementById('drawer-tab-coupling'); \
if (!t) return false; \
t.click(); \
var ov = document.getElementById('drawer-panel-overview'); \
var cp = document.getElementById('drawer-panel-coupling'); \
return ov.classList.contains('hidden') && !cp.classList.contains('hidden') \
&& t.getAttribute('aria-selected') === 'true'; \
})()",
);
assert!(
switched,
"activating the Coupling tab must show it and hide Overview"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn module_chord_colours_clusters() {
let fixture = coupling_repo::build();
let opts = permissive_coupling_opts(fixture.dir.path().to_path_buf());
let repo = GixRepo::open(fixture.dir.path()).expect("open coupling fixture");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
db.ingest(&repo, &opts).expect("ingest coupling fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-chord.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Chord Cluster Test",
&fixture.dir.path().display().to_string(),
"2026-06-20 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let mut cats: i64 = -1;
let mut first_has_cat = false;
for _ in 0..30 {
std::thread::sleep(Duration::from_millis(100));
cats = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-module-chord-body'); \
if (!el || !window.echarts) return -1; \
var chart = window.echarts.getInstanceByDom(el); \
if (!chart) return -1; \
var opt = chart.getOption(); \
var s = opt && opt.series && opt.series[0]; \
if (!s || !s.categories) return -1; \
return s.categories.length; \
})()",
);
if cats >= 1 {
first_has_cat = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-module-chord-body'); \
var chart = window.echarts.getInstanceByDom(el); \
var d = chart.getOption().series[0].data; \
return !!d && d.length > 0 && typeof d[0].category === 'number'; \
})()",
);
break;
}
}
assert!(
cats >= 1,
"module chord should expose at least one ECharts category"
);
assert!(
first_has_cat,
"each chord node should carry a numeric category index"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn hotspot_map_coupling_arcs_name_their_partner() {
let fixture = coupling_repo::build();
let opts = permissive_coupling_opts(fixture.dir.path().to_path_buf());
let repo = GixRepo::open(fixture.dir.path()).expect("open coupling fixture");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
db.ingest(&repo, &opts).expect("ingest coupling fixture");
let hotspots = run_hotspots(&db, &opts).expect("hotspots");
let summary = run_summary(&db, &opts).expect("summary");
let code_health = run_code_health(&db, &opts).expect("code-health");
let coupling = run_coupling(&db, &opts).expect("coupling");
let knowledge_islands = run_knowledge_islands(&db, &opts).expect("knowledge-islands");
let dash = SpaDashboard {
hotspots,
summary,
code_health,
coupling,
knowledge_islands,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-coupling-partners.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore Coupling Partners Test",
&fixture.dir.path().display().to_string(),
"2026-06-20 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let mut peer = String::new();
let candidates: i64 = eval_json(
&tab,
"(function () { \
var tbody = document.getElementById('hotspot-tbody'); \
if (!tbody) return 0; \
var rows = tbody.querySelectorAll('tr[data-path]'); \
window.__codeloreCandidatePaths = []; \
for (var i = 0; i < rows.length; i++) { \
window.__codeloreCandidatePaths.push(rows[i].getAttribute('data-path')); \
} \
return window.__codeloreCandidatePaths.length; \
})()",
);
assert!(
candidates > 0,
"coupling fixture should render hotspot-table rows"
);
for idx in 0..candidates {
let _: bool = eval_json(
&tab,
&format!(
"(function () {{ \
var p = window.__codeloreCandidatePaths[{idx}]; \
window.Alpine.store('selection').set(p); \
return true; \
}})()"
),
);
std::thread::sleep(Duration::from_millis(80));
peer = eval_json(
&tab,
"(function () { \
var el = document.getElementById('widget-hotspot-circle-pack-body'); \
if (!el || !window.echarts) return ''; \
var chart = window.echarts.getInstanceByDom(el); \
if (!chart) return ''; \
var opt = chart.getOption(); \
var arcs = opt && opt.series && opt.series[1] && opt.series[1].data; \
if (arcs && arcs.length && arcs[0]._arc && arcs[0]._arc.peer) { \
return arcs[0]._arc.peer; \
} \
return ''; \
})()",
);
if !peer.is_empty() {
break;
}
}
assert!(
!peer.is_empty(),
"selecting a coupled file should draw a coupling arc whose _arc.peer names \
the partner file — the coupling_repo fixture has file-level coupling"
);
}
#[test]
fn health_trend_toggle_renders_both_views() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-health-trend.html");
write_smoke_spa(&html_path, "CodeLore Health-Trend Toggle Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let overlay_height: i64 = eval_json(
&tab,
"(function () { \
var host = document.getElementById('ht-charts'); \
if (!host) return 0; \
var canvas = host.querySelector('canvas'); \
if (!canvas) return 0; \
return canvas.clientHeight; \
})()",
);
assert!(
overlay_height > 250,
"overlay #ht-charts canvas clientHeight was {overlay_height}px — \
expected >250px (CSS sets 320px); the #ht-charts container may have \
zero or collapsed height (Bug 2)"
);
let toggle_width: i64 = eval_json(
&tab,
"(function () { \
var btn = document.getElementById('ht-toggle'); \
return btn ? btn.offsetWidth : 0; \
})()",
);
assert!(
toggle_width > 40,
"ht-toggle offsetWidth was {toggle_width}px — expected >40px; \
the button may have collapsed to a DaisyUI toggle knob (Bug 1)"
);
let toggle_label: String = eval_json(
&tab,
"(function () { \
var btn = document.getElementById('ht-toggle'); \
return btn ? btn.textContent.trim() : ''; \
})()",
);
assert_eq!(
toggle_label, "Split view",
"ht-toggle label was '{toggle_label}'; expected 'Split view' (overlay is the default view)"
);
tab.find_element("#ht-toggle")
.expect("ht-toggle element")
.click()
.expect("click ht-toggle");
std::thread::sleep(Duration::from_millis(600));
let split_json: String = eval_json(
&tab,
"(function () { \
var panels = document.querySelectorAll('.ht-sm'); \
var heights = Array.from(panels).map(function (p) { \
var c = p.querySelector('canvas'); \
return c ? c.clientHeight : 0; \
}); \
return JSON.stringify(heights); \
})()",
);
let split_heights: Vec<i64> =
serde_json::from_str(&split_json).expect("parse split heights JSON");
assert_eq!(
split_heights.len(),
3,
"expected 3 .ht-sm panels after toggle click, got {}",
split_heights.len()
);
for (i, &h) in split_heights.iter().enumerate() {
assert!(
h > 150,
"split panel {i} canvas clientHeight was {h}px — expected >150px \
(CSS sets 180px; the pre-fix 130px must fail this); panel height \
may have regressed to an unreadable size (Bug 3)"
);
}
tab.find_element("#ht-toggle")
.expect("ht-toggle element (second click)")
.click()
.expect("click ht-toggle second time");
std::thread::sleep(Duration::from_millis(600));
let overlay_back: i64 = eval_json(
&tab,
"(function () { \
var host = document.getElementById('ht-charts'); \
if (!host) return 0; \
var canvas = host.querySelector('canvas'); \
if (!canvas) return 0; \
return canvas.clientHeight; \
})()",
);
assert!(
overlay_back > 250,
"overlay canvas after toggle-back had clientHeight {overlay_back}px — \
expected >250px; the toggle re-render may not have restored the overlay"
);
}
#[test]
fn dsm_fusion_mode_toggle_classifies_cells_without_errors() {
let fixture = coupling_repo::build();
let opts = permissive_coupling_opts(fixture.dir.path().to_path_buf());
let repo = GixRepo::open(fixture.dir.path()).expect("open coupling fixture");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
db.ingest(&repo, &opts).expect("ingest coupling fixture");
let coupling = run_coupling(&db, &opts).expect("coupling");
assert!(
!coupling.is_empty(),
"coupling_repo fixture must produce coupling rows under permissive opts \
(the Fusion precondition)"
);
let dash = SpaDashboard {
hotspots: run_hotspots(&db, &opts).expect("hotspots"),
summary: run_summary(&db, &opts).expect("summary"),
code_health: run_code_health(&db, &opts).expect("code-health"),
knowledge_islands: run_knowledge_islands(&db, &opts).expect("knowledge-islands"),
coupling,
imports: vec![
ImportEdgeRow {
src_path: "src/alpha/svc.rs".to_string(),
target_path: "src/gamma/svc.rs".to_string(),
},
ImportEdgeRow {
src_path: "src/gamma/util.rs".to_string(),
target_path: "src/beta/util.rs".to_string(),
},
],
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-dsm-fusion.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore DSM Fusion Test",
&fixture.dir.path().display().to_string(),
"2026-07-14 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
let toggle_label = element_text(&tab, "wam-mode-toggle");
assert_eq!(
toggle_label, "Fusion",
"wam-mode-toggle label was '{toggle_label}'; expected 'Fusion' \
(structure is the default mode)"
);
let structure_cells = echarts_series_len(&tab, "wam-chart-host");
assert!(
structure_cells > 0,
"structure-mode matrix has no rendered cells"
);
tab.find_element("#wam-mode-toggle")
.expect("wam-mode-toggle element")
.click()
.expect("click wam-mode-toggle");
std::thread::sleep(Duration::from_millis(500));
let fusion_label = element_text(&tab, "wam-mode-toggle");
assert_eq!(
fusion_label, "Structure",
"toggle label did not flip to 'Structure' after entering Fusion mode"
);
let fusion_cells = echarts_series_len(&tab, "wam-chart-host");
assert!(
fusion_cells > structure_cells,
"Fusion-mode cell count ({fusion_cells}) was not greater than structure-mode's \
({structure_cells}); the guaranteed src/alpha\u{2194}src/beta coupling-only pair \
should add a new temporal-only cell that structure mode never draws"
);
let legend_text = element_text(&tab, "wam-legend");
for phrase in [
"agree",
"structural only",
"modularity violation",
"back-edge",
] {
assert!(
legend_text.contains(phrase),
"Fusion legend missing '{phrase}'; legend text was: {legend_text}"
);
}
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"DSM Fusion toggle produced {} browser-console error(s):\n{}",
errors.len(),
errors.join("\n "),
);
}
#[test]
fn arch_matrix_body_grows_to_contain_tall_matrix() {
let fixture = coupling_repo::build();
let opts = permissive_coupling_opts(fixture.dir.path().to_path_buf());
let repo = GixRepo::open(fixture.dir.path()).expect("open coupling fixture");
let db = FactsDb::new_in_memory().expect("in-memory facts db");
db.ingest(&repo, &opts).expect("ingest coupling fixture");
let imports: Vec<ImportEdgeRow> = (0..29)
.map(|i| ImportEdgeRow {
src_path: format!("src/mod{i:02}/f.rs"),
target_path: format!("src/mod{:02}/f.rs", i + 1),
})
.collect();
let dash = SpaDashboard {
hotspots: run_hotspots(&db, &opts).expect("hotspots"),
summary: run_summary(&db, &opts).expect("summary"),
code_health: run_code_health(&db, &opts).expect("code-health"),
knowledge_islands: run_knowledge_islands(&db, &opts).expect("knowledge-islands"),
coupling: run_coupling(&db, &opts).expect("coupling"),
imports,
..SpaDashboard::default()
};
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-dsm-tall.html");
let mut f = std::fs::File::create(&html_path).expect("create html");
write_spa(
&dash,
"CodeLore DSM Tall Matrix Test",
&fixture.dir.path().display().to_string(),
"2026-07-14 00:00:00 UTC",
&mut f,
)
.expect("write_spa");
drop(f);
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
let host_h = client_height(&tab, "wam-chart-host");
let body_h = client_height(&tab, "widget-arch-matrix-body");
assert!(
host_h > 460,
"precondition weak: 30-module host was only {host_h}px; expected >460 \
so a pinned body would visibly overflow"
);
assert!(
body_h >= host_h && body_h > 460,
"widget-arch-matrix-body clientHeight was {body_h}px but the chart host \
was {host_h}px — the body must grow to contain the matrix (>=host, >460) \
instead of staying pinned at the template's 460px fallback"
);
assert!(
sweep_reaches_chart(
&tab,
".widget-body, [id$=\"-body\"], [id$=\"-chart-host\"]",
"wam-chart-host",
),
"fixed resize sweep did not reach the DSM chart on #wam-chart-host"
);
assert!(
!sweep_reaches_chart(&tab, ".widget-body, [id$=\"-body\"]", "wam-chart-host"),
"pre-fix selector unexpectedly matched #wam-chart-host — the regression \
proof would be vacuous"
);
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"tall-matrix render produced {} browser-console error(s):\n{}",
errors.len(),
errors.join("\n "),
);
}
#[test]
fn section_collapse_and_expand_keeps_charts_sized() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-section-collapse.html");
write_smoke_spa(&html_path, "CodeLore Section Collapse Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
let console_errors = attach_exception_sink(&tab);
let hash_before: String = eval_json(&tab, "location.hash");
let series_before = echarts_series_len(&tab, "wam-chart-host");
assert!(
series_before > 0,
"arch-matrix must have rendered cells before collapsing (got {series_before})"
);
let chevron = "document.querySelector('#group-architecture .dash-collapse')";
tab.evaluate(&format!("{chevron}.click()"), false)
.expect("click architecture chevron");
std::thread::sleep(Duration::from_millis(200));
assert_eq!(
client_height(&tab, "group-architecture-grid"),
0,
"group-architecture-grid must be hidden (0 clientHeight) once collapsed"
);
let expanded_attr: String =
eval_json(&tab, &format!("{chevron}.getAttribute('aria-expanded')"));
assert_eq!(
expanded_attr, "false",
"chevron aria-expanded did not flip to false"
);
tab.evaluate(&format!("{chevron}.click()"), false)
.expect("click architecture chevron again");
std::thread::sleep(Duration::from_millis(200));
assert!(
client_height(&tab, "group-architecture-grid") > 0,
"group-architecture-grid must be visible again after re-expanding"
);
let expanded_attr_after: String =
eval_json(&tab, &format!("{chevron}.getAttribute('aria-expanded')"));
assert_eq!(
expanded_attr_after, "true",
"chevron aria-expanded did not flip back to true"
);
let series_after = echarts_series_len(&tab, "wam-chart-host");
assert!(
series_after > 0,
"arch-matrix lost its rendered cells after expand (got {series_after})"
);
let canvas_width: f64 = eval_json(
&tab,
"(function () { \
var host = document.getElementById('wam-chart-host'); \
var canvas = host && host.querySelector('canvas'); \
return canvas ? canvas.width : 0; \
})()",
);
assert!(
canvas_width > 0.0,
"arch-matrix canvas has zero rendered width after expand ({canvas_width})"
);
let hash_after: String = eval_json(&tab, "location.hash");
assert_eq!(
hash_before, hash_after,
"collapsing/expanding a section must never mutate location.hash"
);
let errors = console_errors.lock().expect("console mutex").clone();
assert!(
errors.is_empty(),
"section collapse/expand produced {} browser-console error(s):\n{}",
errors.len(),
errors.join("\n "),
);
}
#[test]
fn laptop_width_renders_single_column() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-laptop-width.html");
write_smoke_spa(&html_path, "CodeLore Laptop Width Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
set_viewport(&tab, 1100, 900);
std::thread::sleep(Duration::from_millis(300));
let (_, _, main_w) = bounding_rect(&tab, "main");
assert!(main_w > 0.0, "main content width was {main_w} at 1100px");
for id in ["#widget-arch-matrix", "#widget-hotspot-table"] {
let (_, _, w) = bounding_rect(&tab, id);
assert!(
w >= 0.9 * main_w,
"{id} width ({w}) was not >= 0.9x the main content width \
({main_w}) at a 1100px viewport — the single-column laptop \
layout regressed"
);
}
}
#[test]
fn desktop_width_pairs_half_widgets() {
let tmp = tempfile::tempdir().expect("tempdir");
let html_path = tmp.path().join("codelore-desktop-width.html");
write_smoke_spa(&html_path, "CodeLore Desktop Width Test");
let Some((_browser, tab)) = boot_spa_tab(&html_path) else {
return;
};
set_viewport(&tab, 1500, 900);
std::thread::sleep(Duration::from_millis(300));
let (_, _, main_w) = bounding_rect(&tab, "main");
let (surfaces_top, _, surfaces_w) = bounding_rect(&tab, "#widget-knowledge-surfaces");
let (islands_top, _, islands_w) = bounding_rect(&tab, "#widget-knowledge-islands");
assert!(
(surfaces_top - islands_top).abs() < 1.0,
"knowledge-surfaces top ({surfaces_top}) and knowledge-islands top \
({islands_top}) are not in the same row at a 1500px viewport"
);
for (label, w) in [
("knowledge-surfaces", surfaces_w),
("knowledge-islands", islands_w),
] {
assert!(
w < 0.6 * main_w,
"{label} width ({w}) was not < 0.6x the main content width \
({main_w}) at a 1500px viewport — the half-width pairing \
regressed"
);
}
}