chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#[test]
|
||||
fn launch_hotkeys_config_round_trips_toml() {
|
||||
use jcode_config_types::{LaunchHotkeyEntry, LaunchHotkeysConfig};
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct W {
|
||||
launch_hotkeys: LaunchHotkeysConfig,
|
||||
}
|
||||
let w = W {
|
||||
launch_hotkeys: LaunchHotkeysConfig {
|
||||
enabled: Some(true),
|
||||
imported: true,
|
||||
entries: vec![
|
||||
LaunchHotkeyEntry {
|
||||
chord: "cmd+;".into(),
|
||||
dir: "/Users/jeremy/jcode-github".into(),
|
||||
label: "jcode-github".into(),
|
||||
self_dev: false,
|
||||
},
|
||||
LaunchHotkeyEntry {
|
||||
chord: "cmd+'".into(),
|
||||
dir: "$HOME".into(),
|
||||
label: "home".into(),
|
||||
self_dev: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
let toml = toml::to_string(&w).unwrap();
|
||||
let back: W = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(back.launch_hotkeys.entries.len(), 2);
|
||||
assert_eq!(back.launch_hotkeys.enabled, Some(true));
|
||||
// Empty config (no section) -> defaults: no entries, enabled None.
|
||||
let empty: W = toml::from_str("").unwrap();
|
||||
assert!(empty.launch_hotkeys.entries.is_empty());
|
||||
assert_eq!(empty.launch_hotkeys.enabled, None);
|
||||
assert!(!empty.launch_hotkeys.imported);
|
||||
}
|
||||
Reference in New Issue
Block a user