a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
380 lines
16 KiB
Rust
380 lines
16 KiB
Rust
//! End-to-end Claude Code import integration tests (swarm Worker C / dolphin).
|
|
//!
|
|
//! These drive the real `jcode_base::import` pipeline against synthetic
|
|
//! `.jsonl` fixtures written into a sandboxed `JCODE_HOME`, so they never touch
|
|
//! the developer's real `~/.claude`. They assert the *target* behavior agreed
|
|
//! with the swarm coordinator:
|
|
//!
|
|
//! * a single malformed/odd block must never drop the whole line;
|
|
//! * a tool_result image must import as a `[image]` placeholder, never base64;
|
|
//! * sidechain entries are excluded from the imported transcript;
|
|
//! * one malformed JSON line is skipped while good lines around it import;
|
|
//! * empty / meta-only transcripts are filtered out of the session list;
|
|
//! * a sessions-index.json pointing at a missing path falls back to the
|
|
//! sibling `<id>.jsonl` file (resolve_claude_session_path).
|
|
//!
|
|
//! All tests serialize on `lock_test_env()` because they mutate the process
|
|
//! environment (`JCODE_HOME`).
|
|
|
|
use jcode_base::import::{
|
|
import_session, import_session_from_file, list_claude_code_sessions,
|
|
list_claude_code_sessions_lazy,
|
|
};
|
|
use jcode_base::message::ContentBlock;
|
|
use jcode_base::session::Session;
|
|
use jcode_import_core::imported_claude_code_session_id;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
|
|
/// Serializes tests that mutate `JCODE_HOME`. This test binary has its own
|
|
/// address space, so a file-local mutex is sufficient (and avoids depending on
|
|
/// the `test-support`-gated `storage::lock_test_env`).
|
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
/// RAII guard that sets/restores an env var, mirroring `import_tests.rs`.
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
prev: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set_path(key: &'static str, value: &Path) -> Self {
|
|
let prev = std::env::var_os(key);
|
|
jcode_base::env::set_var(key, value);
|
|
Self { key, prev }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(prev) = self.prev.take() {
|
|
jcode_base::env::set_var(self.key, prev);
|
|
} else {
|
|
jcode_base::env::remove_var(self.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create the sandboxed Claude project dir under `$JCODE_HOME/external/...`.
|
|
fn make_project_dir(home: &Path) -> PathBuf {
|
|
let dir = home.join("external/.claude/projects/-Users-demo");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn all_text(session: &Session) -> String {
|
|
session
|
|
.messages
|
|
.iter()
|
|
.flat_map(|m| m.content.iter())
|
|
.filter_map(|b| match b {
|
|
ContentBlock::Text { text, .. } => Some(text.clone()),
|
|
ContentBlock::ToolResult { content, .. } => Some(content.clone()),
|
|
ContentBlock::Reasoning { text } => Some(text.clone()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Image tool_result imports as a placeholder, never base64 (edge cases 1 + 9)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn import_array_tool_result_with_image_uses_placeholder_not_base64() {
|
|
let _guard = ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
let project_dir = make_project_dir(temp.path());
|
|
|
|
let big = "Z".repeat(80_000);
|
|
let path = project_dir.join("img-session.jsonl");
|
|
let line_user = r#"{"type":"user","uuid":"u1","sessionId":"img-session","cwd":"/tmp/demo","message":{"role":"user","content":"take a screenshot"},"timestamp":"2026-05-01T10:00:00Z"}"#;
|
|
// assistant requests the screenshot tool
|
|
let line_tooluse = r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"img-session","message":{"role":"assistant","model":"claude-sonnet-4-6","content":[{"type":"tool_use","id":"call_1","name":"screenshot","input":{}}]},"timestamp":"2026-05-01T10:00:01Z"}"#;
|
|
// user delivers an ARRAY-form tool_result with text + a huge base64 image
|
|
let line_toolresult = format!(
|
|
r#"{{"type":"user","uuid":"u2","parentUuid":"a1","sessionId":"img-session","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"call_1","content":[{{"type":"text","text":"captured"}},{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{big}"}}}}]}}]}},"timestamp":"2026-05-01T10:00:02Z"}}"#
|
|
);
|
|
std::fs::write(
|
|
&path,
|
|
format!("{line_user}\n{line_tooluse}\n{line_toolresult}\n"),
|
|
)
|
|
.unwrap();
|
|
|
|
let session = import_session_from_file(&path, "img-session").unwrap();
|
|
|
|
// All three message lines survived (none dropped).
|
|
assert_eq!(
|
|
session.messages.len(),
|
|
3,
|
|
"expected user+assistant+toolresult to all import, got {}",
|
|
session.messages.len()
|
|
);
|
|
|
|
let text = all_text(&session);
|
|
assert!(text.contains("captured"), "tool_result text must survive");
|
|
assert!(text.contains("[image]"), "image must become a placeholder");
|
|
assert!(
|
|
!text.contains(&big),
|
|
"base64 image data must NOT be embedded in the imported transcript"
|
|
);
|
|
|
|
// The persisted on-disk session must also be free of base64 bloat.
|
|
let saved = std::fs::read_to_string(jcode_session_file(temp.path(), "img-session")).unwrap();
|
|
assert!(
|
|
!saved.contains(&big),
|
|
"persisted session JSON must not contain base64 image data"
|
|
);
|
|
}
|
|
|
|
/// Path to where jcode persists imported sessions under the sandbox.
|
|
/// With `JCODE_HOME` set, `storage::jcode_dir()` returns it directly, and
|
|
/// sessions live at `<JCODE_HOME>/sessions/<id>.json`.
|
|
fn jcode_session_file(home: &Path, claude_id: &str) -> PathBuf {
|
|
let id = imported_claude_code_session_id(claude_id);
|
|
home.join(format!("sessions/{id}.json"))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// null / missing tool_result content (edge case 1)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn import_tool_result_null_and_missing_content_does_not_drop_lines() {
|
|
let _guard = ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
let project_dir = make_project_dir(temp.path());
|
|
|
|
let path = project_dir.join("nullc.jsonl");
|
|
let lines = concat!(
|
|
r#"{"type":"user","uuid":"u1","sessionId":"nullc","message":{"role":"user","content":"hi"},"timestamp":"2026-05-01T10:00:00Z"}"#,
|
|
"\n",
|
|
// tool_result content == null, plus a sibling text block so the message is non-empty
|
|
r#"{"type":"user","uuid":"u2","parentUuid":"u1","sessionId":"nullc","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":null},{"type":"text","text":"after null"}]},"timestamp":"2026-05-01T10:00:01Z"}"#,
|
|
"\n",
|
|
// tool_result content missing entirely, plus sibling text
|
|
r#"{"type":"assistant","uuid":"a1","parentUuid":"u2","sessionId":"nullc","message":{"role":"assistant","content":[{"type":"tool_result","tool_use_id":"t2"},{"type":"text","text":"after missing"}]},"timestamp":"2026-05-01T10:00:02Z"}"#,
|
|
"\n",
|
|
);
|
|
std::fs::write(&path, lines).unwrap();
|
|
|
|
let session = import_session_from_file(&path, "nullc").unwrap();
|
|
assert_eq!(session.messages.len(), 3, "no line should be dropped");
|
|
let text = all_text(&session);
|
|
assert!(text.contains("after null"));
|
|
assert!(text.contains("after missing"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sidechain exclusion through the import pipeline (edge case 4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn import_excludes_sidechain_messages() {
|
|
let _guard = ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
let project_dir = make_project_dir(temp.path());
|
|
|
|
let path = project_dir.join("side.jsonl");
|
|
let lines = concat!(
|
|
r#"{"type":"user","uuid":"m1","sessionId":"side","message":{"role":"user","content":"main question"},"timestamp":"2026-05-01T10:00:00Z"}"#,
|
|
"\n",
|
|
r#"{"type":"assistant","uuid":"s1","parentUuid":"m1","isSidechain":true,"sessionId":"side","message":{"role":"assistant","content":"SIDECHAIN_SECRET"},"timestamp":"2026-05-01T10:00:01Z"}"#,
|
|
"\n",
|
|
r#"{"type":"assistant","uuid":"m2","parentUuid":"m1","sessionId":"side","message":{"role":"assistant","content":"main answer"},"timestamp":"2026-05-01T10:00:02Z"}"#,
|
|
"\n",
|
|
);
|
|
std::fs::write(&path, lines).unwrap();
|
|
|
|
let session = import_session_from_file(&path, "side").unwrap();
|
|
let text = all_text(&session);
|
|
assert!(
|
|
!text.contains("SIDECHAIN_SECRET"),
|
|
"sidechain message must be excluded from imported transcript"
|
|
);
|
|
assert!(text.contains("main question"));
|
|
assert!(text.contains("main answer"));
|
|
assert_eq!(session.messages.len(), 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Malformed JSON lines skipped individually (edge case 6)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn import_skips_only_malformed_lines() {
|
|
let _guard = ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
let project_dir = make_project_dir(temp.path());
|
|
|
|
let path = project_dir.join("mixed.jsonl");
|
|
let lines = concat!(
|
|
r#"{"type":"user","uuid":"u1","sessionId":"mixed","message":{"role":"user","content":"good one"},"timestamp":"2026-05-01T10:00:00Z"}"#,
|
|
"\n",
|
|
"{ this is not valid json at all }\n",
|
|
"\n", // blank line
|
|
r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"mixed","message":{"role":"assistant","content":"good two"},"timestamp":"2026-05-01T10:00:02Z"}"#,
|
|
"\n",
|
|
);
|
|
std::fs::write(&path, lines).unwrap();
|
|
|
|
let session = import_session_from_file(&path, "mixed").unwrap();
|
|
assert_eq!(
|
|
session.messages.len(),
|
|
2,
|
|
"only the malformed line should be skipped"
|
|
);
|
|
let text = all_text(&session);
|
|
assert!(text.contains("good one"));
|
|
assert!(text.contains("good two"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Empty / meta-only transcripts filtered from the session list (edge case 7)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn list_filters_empty_and_meta_only_transcripts() {
|
|
let _guard = ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
let project_dir = make_project_dir(temp.path());
|
|
|
|
// Empty file.
|
|
std::fs::write(project_dir.join("empty.jsonl"), "").unwrap();
|
|
// Only summary / system meta lines, no user/assistant messages.
|
|
std::fs::write(
|
|
project_dir.join("meta-only.jsonl"),
|
|
concat!(
|
|
r#"{"type":"summary","summary":"Some summary","leafUuid":"x"}"#,
|
|
"\n",
|
|
r#"{"type":"system","sessionId":"meta-only","content":"boot"}"#,
|
|
"\n",
|
|
),
|
|
)
|
|
.unwrap();
|
|
// A real session that SHOULD show up.
|
|
std::fs::write(
|
|
project_dir.join("real.jsonl"),
|
|
concat!(
|
|
r#"{"type":"user","uuid":"u1","sessionId":"real","message":{"role":"user","content":"real prompt"},"timestamp":"2026-05-01T10:00:00Z"}"#, "\n",
|
|
r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"real","message":{"role":"assistant","content":"real answer"},"timestamp":"2026-05-01T10:00:01Z"}"#, "\n",
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let sessions = list_claude_code_sessions().unwrap();
|
|
let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect();
|
|
assert!(
|
|
ids.contains(&"real"),
|
|
"real session must be listed: {ids:?}"
|
|
);
|
|
assert!(
|
|
!ids.contains(&"empty"),
|
|
"empty transcript must be filtered out: {ids:?}"
|
|
);
|
|
assert!(
|
|
!ids.contains(&"meta-only"),
|
|
"meta-only transcript must be filtered out: {ids:?}"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// sessions-index.json pointing at a missing path -> fallback to sibling file
|
|
// (edge case 8, resolve_claude_session_path)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn index_with_missing_full_path_falls_back_to_sibling_jsonl() {
|
|
let _guard = ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
let project_dir = make_project_dir(temp.path());
|
|
|
|
// The actual transcript lives next to the index under <id>.jsonl ...
|
|
let transcript = project_dir.join("renamed-session.jsonl");
|
|
std::fs::write(
|
|
&transcript,
|
|
concat!(
|
|
r#"{"type":"user","uuid":"u1","sessionId":"renamed-session","cwd":"/tmp/demo","message":{"role":"user","content":"recover me"},"timestamp":"2026-05-01T10:00:00Z"}"#, "\n",
|
|
r#"{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"renamed-session","message":{"role":"assistant","model":"claude-sonnet-4-6","content":"recovered"},"timestamp":"2026-05-01T10:00:01Z"}"#, "\n",
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
// ... but the index points fullPath at a stale/missing location.
|
|
std::fs::write(
|
|
project_dir.join("sessions-index.json"),
|
|
concat!(
|
|
"{\"version\":1,\"entries\":[",
|
|
"{\"sessionId\":\"renamed-session\",",
|
|
"\"fullPath\":\"/nonexistent/old/renamed-session.jsonl\",",
|
|
"\"firstPrompt\":\"recover me\",",
|
|
"\"summary\":\"recover me\",",
|
|
"\"messageCount\":2,",
|
|
"\"created\":\"2026-05-01T10:00:00Z\",",
|
|
"\"modified\":\"2026-05-01T10:00:01Z\",",
|
|
"\"projectPath\":\"/tmp/demo\"",
|
|
"}]}"
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let sessions = list_claude_code_sessions().unwrap();
|
|
let found = sessions
|
|
.iter()
|
|
.find(|s| s.session_id == "renamed-session")
|
|
.expect("session must resolve via sibling-file fallback");
|
|
assert_eq!(found.full_path, transcript.to_string_lossy());
|
|
|
|
// And it must actually import from the recovered path.
|
|
let imported = import_session("renamed-session").unwrap();
|
|
assert_eq!(imported.messages.len(), 2);
|
|
assert!(all_text(&imported).contains("recovered"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lazy lister tolerates a huge/odd transcript without choking (robustness)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn lazy_lister_handles_array_tool_result_session() {
|
|
let _guard = ENV_LOCK
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let temp = tempfile::TempDir::new().unwrap();
|
|
let _home = EnvVarGuard::set_path("JCODE_HOME", temp.path());
|
|
let project_dir = make_project_dir(temp.path());
|
|
|
|
let big = "Y".repeat(40_000);
|
|
let path = project_dir.join("lazy.jsonl");
|
|
let line = format!(
|
|
r#"{{"type":"user","uuid":"u1","sessionId":"lazy","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1","content":[{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{big}"}}}}]}}]}},"timestamp":"2026-05-01T10:00:00Z"}}"#
|
|
);
|
|
std::fs::write(&path, format!("{line}\n")).unwrap();
|
|
|
|
// Should not panic and should surface the session id.
|
|
let sessions = list_claude_code_sessions_lazy(50).unwrap();
|
|
assert!(
|
|
sessions.iter().any(|s| s.session_id == "lazy"),
|
|
"lazy lister should detect the session"
|
|
);
|
|
}
|