| 1 | //! Smoke test against the host's real Claude Code data. Skipped if |
| 2 | //! `~/.claude/projects/` is missing. Not deterministic, not run in CI — |
| 3 | //! here as a manual sanity check during development. |
| 4 | |
| 5 | use std::path::PathBuf; |
| 6 | |
| 7 | use claudex_lib::core::discovery::scan_projects; |
| 8 | use claudex_lib::core::grouping::build_projects; |
| 9 | use claudex_lib::core::metadata::summarize; |
| 10 | use claudex_lib::core::paths::projects_dir; |
| 11 | use claudex_lib::core::reader::read_session; |
| 12 | use claudex_lib::core::schema::{Message, ProjectCategory}; |
| 13 | |
| 14 | #[test] |
| 15 | fn summarizes_every_session_on_host_without_panicking() { |
| 16 | let root = match projects_dir() { |
| 17 | Ok(p) if p.exists() => p, |
| 18 | _ => { |
| 19 | eprintln!("skipping: no ~/.claude/projects/"); |
| 20 | return; |
| 21 | } |
| 22 | }; |
| 23 | |
| 24 | let projects = scan_projects(&root).expect("scan"); |
| 25 | let mut total = 0usize; |
| 26 | let mut with_title = 0usize; |
| 27 | let mut with_timestamp = 0usize; |
| 28 | |
| 29 | for project in &projects { |
| 30 | for session in &project.sessions { |
| 31 | total += 1; |
| 32 | let summary = match summarize(session.as_path(), &project.id) { |
| 33 | Ok(s) => s, |
| 34 | Err(e) => panic!("summarize failed on {:?}: {e}", session), |
| 35 | }; |
| 36 | if summary.title != "(untitled)" { |
| 37 | with_title += 1; |
| 38 | } |
| 39 | if summary.last_activity_at.is_some() { |
| 40 | with_timestamp += 1; |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | eprintln!( |
| 46 | "host smoke (summarize): {total} sessions across {} raw dirs, {with_title} titled, {with_timestamp} with timestamp", |
| 47 | projects.len() |
| 48 | ); |
| 49 | assert!(total > 0, "expected at least one real session on host"); |
| 50 | let _ = PathBuf::new(); |
| 51 | } |
| 52 | |
| 53 | #[test] |
| 54 | fn groups_real_projects_via_git_root_and_prefix_fallback() { |
| 55 | let root = match projects_dir() { |
| 56 | Ok(p) if p.exists() => p, |
| 57 | _ => { |
| 58 | eprintln!("skipping: no ~/.claude/projects/"); |
| 59 | return; |
| 60 | } |
| 61 | }; |
| 62 | |
| 63 | let dirs = scan_projects(&root).expect("scan"); |
| 64 | let mut pairs = Vec::new(); |
| 65 | for dir in dirs { |
| 66 | for session in &dir.sessions { |
| 67 | if let Ok(summary) = summarize(session, &dir.id) { |
| 68 | pairs.push((summary, dir.id.clone())); |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | let before = pairs |
| 74 | .iter() |
| 75 | .map(|(_, e)| e.clone()) |
| 76 | .collect::<std::collections::BTreeSet<_>>() |
| 77 | .len(); |
| 78 | |
| 79 | let projects = build_projects(pairs); |
| 80 | |
| 81 | let regular: Vec<_> = projects |
| 82 | .iter() |
| 83 | .filter(|p| p.category == ProjectCategory::Regular) |
| 84 | .collect(); |
| 85 | let observers: Vec<_> = projects |
| 86 | .iter() |
| 87 | .filter(|p| p.category == ProjectCategory::Observer) |
| 88 | .collect(); |
| 89 | |
| 90 | eprintln!( |
| 91 | "host smoke (grouping): {before} raw encoded dirs collapsed into {} projects ({} regular + {} observer)", |
| 92 | projects.len(), |
| 93 | regular.len(), |
| 94 | observers.len() |
| 95 | ); |
| 96 | eprintln!("top 10 regular projects by recency:"); |
| 97 | for p in regular.iter().take(10) { |
| 98 | let mut source_dirs = p.source_dirs.clone(); |
| 99 | source_dirs.sort(); |
| 100 | eprintln!( |
| 101 | " {:>4} sess [{}] {}", |
| 102 | p.session_count, |
| 103 | source_dirs.len(), |
| 104 | p.display_name |
| 105 | ); |
| 106 | if source_dirs.len() > 1 { |
| 107 | for d in &source_dirs { |
| 108 | eprintln!(" ↳ {d}"); |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | eprintln!("observer projects:"); |
| 113 | for p in &observers { |
| 114 | eprintln!(" {:>4} sess {}", p.session_count, p.display_name); |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | #[test] |
| 119 | fn shows_real_titles_after_sanitizer_and_claude_mem_enrichment() { |
| 120 | use claudex_lib::core::claude_mem; |
| 121 | use claudex_lib::core::schema::SessionSummary; |
| 122 | |
| 123 | let root = match projects_dir() { |
| 124 | Ok(p) if p.exists() => p, |
| 125 | _ => { |
| 126 | eprintln!("skipping: no ~/.claude/projects/"); |
| 127 | return; |
| 128 | } |
| 129 | }; |
| 130 | |
| 131 | let dirs = scan_projects(&root).expect("scan"); |
| 132 | let mut pairs: Vec<(SessionSummary, String)> = Vec::new(); |
| 133 | for dir in dirs { |
| 134 | for session in &dir.sessions { |
| 135 | if let Ok(summary) = summarize(session, &dir.id) { |
| 136 | pairs.push((summary, dir.id.clone())); |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // Mirror the command-layer enrichment path. |
| 142 | if let Some(db_path) = claude_mem::default_db_path() { |
| 143 | let map = claude_mem::load_title_map(&db_path); |
| 144 | eprintln!( |
| 145 | "claude-mem title map: {} entries (db present: {})", |
| 146 | map.len(), |
| 147 | db_path.exists() |
| 148 | ); |
| 149 | if !map.is_empty() { |
| 150 | for (s, _) in pairs.iter_mut() { |
| 151 | if s.custom_title.is_some() { |
| 152 | continue; |
| 153 | } |
| 154 | if let Some(t) = map.get(&s.id).and_then(|t| t.best()) { |
| 155 | s.title = t.to_string(); |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | let projects = build_projects(pairs); |
| 162 | let regular: Vec<_> = projects |
| 163 | .iter() |
| 164 | .filter(|p| p.category == ProjectCategory::Regular) |
| 165 | .collect(); |
| 166 | |
| 167 | eprintln!("titles for regular projects (top 10 by recency):"); |
| 168 | for p in regular.iter().take(10) { |
| 169 | eprintln!(" === {} ({} sessions)", p.display_name, p.session_count); |
| 170 | for s in p.sessions.iter().take(3) { |
| 171 | eprintln!(" {}", s.title); |
| 172 | } |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | #[test] |
| 177 | fn discovers_archive_projects_from_host_history_log() { |
| 178 | use claudex_lib::core::grouping::build_archive_projects; |
| 179 | use claudex_lib::core::history_log::{default_history_path, HistoryLog}; |
| 180 | |
| 181 | let root = match projects_dir() { |
| 182 | Ok(p) if p.exists() => p, |
| 183 | _ => { |
| 184 | eprintln!("skipping: no ~/.claude/projects/"); |
| 185 | return; |
| 186 | } |
| 187 | }; |
| 188 | let Some(history_path) = default_history_path() else { |
| 189 | eprintln!("skipping: no home dir"); |
| 190 | return; |
| 191 | }; |
| 192 | if !history_path.exists() { |
| 193 | eprintln!("skipping: no ~/.claude/history.jsonl"); |
| 194 | return; |
| 195 | } |
| 196 | |
| 197 | // Build the disk projects. |
| 198 | let dirs = scan_projects(&root).expect("scan"); |
| 199 | let mut pairs = Vec::new(); |
| 200 | for dir in dirs { |
| 201 | for session in &dir.sessions { |
| 202 | if let Ok(s) = summarize(session, &dir.id) { |
| 203 | pairs.push((s, dir.id.clone())); |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | let disk_projects = claudex_lib::core::grouping::build_projects(pairs); |
| 208 | |
| 209 | // Load the history log. |
| 210 | let log = HistoryLog::load(&history_path); |
| 211 | eprintln!( |
| 212 | "host history.jsonl: {} projects, {} entries total", |
| 213 | log.project_count(), |
| 214 | log.total_entries() |
| 215 | ); |
| 216 | |
| 217 | // Build archive projects. |
| 218 | let archive = build_archive_projects(&log, &disk_projects); |
| 219 | eprintln!("\narchive projects discovered: {}", archive.len()); |
| 220 | for p in archive.iter().take(20) { |
| 221 | let sess = &p.sessions[0]; |
| 222 | eprintln!( |
| 223 | " [{:>4} prompts] {} — {}", |
| 224 | sess.message_count, p.display_name, sess.title |
| 225 | ); |
| 226 | } |
| 227 | |
| 228 | assert!( |
| 229 | log.project_count() > 0, |
| 230 | "history.jsonl should have at least one project on a real machine" |
| 231 | ); |
| 232 | |
| 233 | // Assert that disk projects don't leak into archive (no |
| 234 | // duplicates). |
| 235 | for ap in &archive { |
| 236 | for dp in &disk_projects { |
| 237 | assert_ne!( |
| 238 | ap.id, dp.id, |
| 239 | "archive project {:?} collides with disk project", |
| 240 | ap.id |
| 241 | ); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // All archive projects should have category=Archive and exactly |
| 246 | // one synthetic session with source=Archive. |
| 247 | for p in &archive { |
| 248 | assert_eq!( |
| 249 | p.category, |
| 250 | claudex_lib::core::schema::ProjectCategory::Archive |
| 251 | ); |
| 252 | assert_eq!(p.sessions.len(), 1); |
| 253 | assert_eq!( |
| 254 | p.sessions[0].source, |
| 255 | claudex_lib::core::schema::SessionSource::Archive |
| 256 | ); |
| 257 | assert!(p.sessions[0].message_count > 0); |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | #[test] |
| 262 | fn reads_every_session_fully_without_panicking() { |
| 263 | let root = match projects_dir() { |
| 264 | Ok(p) if p.exists() => p, |
| 265 | _ => { |
| 266 | eprintln!("skipping: no ~/.claude/projects/"); |
| 267 | return; |
| 268 | } |
| 269 | }; |
| 270 | |
| 271 | let projects = scan_projects(&root).expect("scan"); |
| 272 | let mut total_sessions = 0usize; |
| 273 | let mut total_messages = 0usize; |
| 274 | let mut unknown_types: std::collections::BTreeMap<String, usize> = |
| 275 | std::collections::BTreeMap::new(); |
| 276 | let mut user_count = 0usize; |
| 277 | let mut assistant_count = 0usize; |
| 278 | let mut tool_use_count = 0usize; |
| 279 | |
| 280 | for project in &projects { |
| 281 | for session in &project.sessions { |
| 282 | total_sessions += 1; |
| 283 | let detail = match read_session(session.as_path(), &project.id) { |
| 284 | Ok(d) => d, |
| 285 | Err(e) => panic!("read_session failed on {:?}: {e}", session), |
| 286 | }; |
| 287 | total_messages += detail.messages.len(); |
| 288 | for m in &detail.messages { |
| 289 | match m { |
| 290 | Message::User { .. } => user_count += 1, |
| 291 | Message::Assistant { blocks, .. } => { |
| 292 | assistant_count += 1; |
| 293 | for b in blocks { |
| 294 | if let claudex_lib::core::schema::ContentBlock::ToolUse { .. } = b { |
| 295 | tool_use_count += 1; |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | Message::Unknown { raw_type, .. } => { |
| 300 | *unknown_types.entry(raw_type.clone()).or_default() += 1; |
| 301 | } |
| 302 | _ => {} |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | eprintln!( |
| 309 | "host smoke (read_session): {total_sessions} sessions, {total_messages} messages, \ |
| 310 | {user_count} user, {assistant_count} assistant, {tool_use_count} tool_use blocks" |
| 311 | ); |
| 312 | if !unknown_types.is_empty() { |
| 313 | eprintln!("unknown event types encountered (would render as Unknown cards):"); |
| 314 | for (t, c) in &unknown_types { |
| 315 | eprintln!(" {t}: {c}"); |
| 316 | } |
| 317 | } |
| 318 | } |
| 319 |