diff --git a/Makefile b/Makefile index a2d5f01b4..457f314ef 100644 --- a/Makefile +++ b/Makefile @@ -65,6 +65,7 @@ test: lint cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features test-tauri -- --test-threads=1 cargo test --manifest-path src-tauri/plugins/tauri-plugin-hardware/Cargo.toml cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml + cargo test --manifest-path src-tauri/utils/Cargo.toml # Builds and publishes the app build-and-publish: install-and-build diff --git a/mise.toml b/mise.toml index 931603999..e30b8ba41 100644 --- a/mise.toml +++ b/mise.toml @@ -138,11 +138,56 @@ description = "Run linting (matches Makefile)" depends = ["build-extensions"] run = "yarn lint" -[tasks.test] -description = "Run test suite (matches Makefile)" -depends = ["lint"] +# ============================================================================ +# RUST TEST COMPONENTS +# ============================================================================ + +[tasks.test-rust-main] +description = "Test main src-tauri package" +run = "cargo test --manifest-path src-tauri/Cargo.toml --no-default-features --features test-tauri -- --test-threads=1" + +[tasks.test-rust-hardware] +description = "Test hardware plugin" +run = "cargo test --manifest-path src-tauri/plugins/tauri-plugin-hardware/Cargo.toml" + +[tasks.test-rust-llamacpp] +description = "Test llamacpp plugin" +run = "cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml" + +[tasks.test-rust-utils] +description = "Test utils package" +run = "cargo test --manifest-path src-tauri/utils/Cargo.toml" + +[tasks.test-rust] +description = "Run all Rust tests" +depends = ["test-rust-main", "test-rust-hardware", "test-rust-llamacpp", "test-rust-utils"] + +# ============================================================================ +# JS TEST COMPONENTS +# ============================================================================ + +[tasks.test-js-setup] +description = "Setup for JS tests" +run = [ + "yarn download:bin", + "yarn download:lib", + "yarn copy:assets:tauri", + "yarn build:icon" +] + +[tasks.test-js] +description = "Run JS tests" +depends = ["test-js-setup"] run = "yarn test" +# ============================================================================ +# COMBINED TEST TASKS +# ============================================================================ + +[tasks.test] +description = "Run complete test suite (matches Makefile)" +depends = ["lint", "test-js", "test-rust"] + # ============================================================================ # PARALLEL-FRIENDLY QUALITY ASSURANCE TASKS # ============================================================================ @@ -155,8 +200,7 @@ hide = true [tasks.test-only] description = "Run tests only (parallel-friendly)" -depends = ["build-extensions"] -run = "yarn test" +depends = ["build-extensions", "test-js", "test-rust"] hide = true [tasks.qa-parallel] diff --git a/package.json b/package.json index ba2704e57..34c7d3b0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "jan-app", "private": true, + "type": "module", "workspaces": { "packages": [ "core", diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml b/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml index 0defc62b7..c0370c3ad 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml +++ b/src-tauri/plugins/tauri-plugin-llamacpp/Cargo.toml @@ -33,5 +33,8 @@ windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] } [target.'cfg(unix)'.dependencies] nix = { version = "=0.30.1", features = ["signal", "process"] } +[dev-dependencies] +tempfile = "3.0" + [build-dependencies] tauri-plugin = { version = "2.3.1", features = ["build"] } diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/device.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/device.rs index 2bb2b6f92..80b0293ac 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/device.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/device.rs @@ -228,3 +228,186 @@ fn parse_memory_value(mem_str: &str) -> ServerResult { .into() }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_memory_pattern_valid() { + assert!(is_memory_pattern("8128 MiB, 8128 MiB free")); + assert!(is_memory_pattern("1024 MiB, 512 MiB free")); + assert!(is_memory_pattern("16384 MiB, 12000 MiB free")); + assert!(is_memory_pattern("0 MiB, 0 MiB free")); + } + + #[test] + fn test_is_memory_pattern_invalid() { + assert!(!is_memory_pattern("8128 MB, 8128 MB free")); // Wrong unit + assert!(!is_memory_pattern("8128 MiB 8128 MiB free")); // Missing comma + assert!(!is_memory_pattern("8128 MiB, 8128 MiB used")); // Wrong second part + assert!(!is_memory_pattern("not_a_number MiB, 8128 MiB free")); // Invalid number + assert!(!is_memory_pattern("8128 MiB")); // Missing second part + assert!(!is_memory_pattern("")); // Empty string + assert!(!is_memory_pattern("8128 MiB, free")); // Missing number in second part + } + + #[test] + fn test_find_memory_pattern() { + let text = "Intel(R) Arc(tm) A750 Graphics (DG2) (8128 MiB, 4096 MiB free)"; + let result = find_memory_pattern(text); + assert!(result.is_some()); + let (start_idx, content) = result.unwrap(); + assert!(start_idx > 0); + assert_eq!(content, "8128 MiB, 4096 MiB free"); + } + + #[test] + fn test_find_memory_pattern_multiple_parentheses() { + let text = "Device (test) with (1024 MiB, 512 MiB free) and (2048 MiB, 1024 MiB free)"; + let result = find_memory_pattern(text); + assert!(result.is_some()); + let (_, content) = result.unwrap(); + // Should return the LAST valid memory pattern + assert_eq!(content, "2048 MiB, 1024 MiB free"); + } + + #[test] + fn test_find_memory_pattern_no_match() { + let text = "No memory info here"; + assert!(find_memory_pattern(text).is_none()); + + let text_with_invalid = "Some text (invalid memory info) here"; + assert!(find_memory_pattern(text_with_invalid).is_none()); + } + + #[test] + fn test_parse_memory_value() { + assert_eq!(parse_memory_value("8128 MiB").unwrap(), 8128); + assert_eq!(parse_memory_value("7721 MiB free").unwrap(), 7721); + assert_eq!(parse_memory_value("0 MiB").unwrap(), 0); + assert_eq!(parse_memory_value("24576 MiB").unwrap(), 24576); + } + + #[test] + fn test_parse_memory_value_invalid() { + assert!(parse_memory_value("").is_err()); + assert!(parse_memory_value("not_a_number MiB").is_err()); + assert!(parse_memory_value(" ").is_err()); + } + + #[test] + fn test_parse_device_line_vulkan() { + let line = "Vulkan0: Intel(R) Arc(tm) A750 Graphics (DG2) (8128 MiB, 8128 MiB free)"; + let result = parse_device_line(line).unwrap(); + assert!(result.is_some()); + let device = result.unwrap(); + assert_eq!(device.id, "Vulkan0"); + assert_eq!(device.name, "Intel(R) Arc(tm) A750 Graphics (DG2)"); + assert_eq!(device.mem, 8128); + assert_eq!(device.free, 8128); + } + + #[test] + fn test_parse_device_line_cuda() { + let line = "CUDA0: NVIDIA GeForce RTX 4090 (24576 MiB, 24000 MiB free)"; + let result = parse_device_line(line).unwrap(); + assert!(result.is_some()); + let device = result.unwrap(); + assert_eq!(device.id, "CUDA0"); + assert_eq!(device.name, "NVIDIA GeForce RTX 4090"); + assert_eq!(device.mem, 24576); + assert_eq!(device.free, 24000); + } + + #[test] + fn test_parse_device_line_sycl() { + let line = "SYCL0: Intel(R) Arc(TM) A750 Graphics (8000 MiB, 7721 MiB free)"; + let result = parse_device_line(line).unwrap(); + assert!(result.is_some()); + let device = result.unwrap(); + assert_eq!(device.id, "SYCL0"); + assert_eq!(device.name, "Intel(R) Arc(TM) A750 Graphics"); + assert_eq!(device.mem, 8000); + assert_eq!(device.free, 7721); + } + + #[test] + fn test_parse_device_line_malformed() { + // Missing colon + let result = parse_device_line("Vulkan0 Intel Graphics (8128 MiB, 8128 MiB free)").unwrap(); + assert!(result.is_none()); + + // Missing memory info + let result = parse_device_line("Vulkan0: Intel Graphics").unwrap(); + assert!(result.is_none()); + + // Invalid memory format + let result = parse_device_line("Vulkan0: Intel Graphics (invalid memory)").unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_parse_device_output_valid() { + let output = r#" +Some header text +Available devices: +Vulkan0: Intel(R) Arc(tm) A750 Graphics (DG2) (8128 MiB, 8128 MiB free) +CUDA0: NVIDIA GeForce RTX 4090 (24576 MiB, 24000 MiB free) + +SYCL0: Intel(R) Arc(TM) A750 Graphics (8000 MiB, 7721 MiB free) +Some footer text +"#; + + let result = parse_device_output(output).unwrap(); + assert_eq!(result.len(), 3); + + assert_eq!(result[0].id, "Vulkan0"); + assert_eq!(result[0].name, "Intel(R) Arc(tm) A750 Graphics (DG2)"); + assert_eq!(result[0].mem, 8128); + + assert_eq!(result[1].id, "CUDA0"); + assert_eq!(result[1].name, "NVIDIA GeForce RTX 4090"); + assert_eq!(result[1].mem, 24576); + + assert_eq!(result[2].id, "SYCL0"); + assert_eq!(result[2].name, "Intel(R) Arc(TM) A750 Graphics"); + assert_eq!(result[2].mem, 8000); + } + + #[test] + fn test_parse_device_output_no_devices_section() { + let output = "Some output without Available devices section"; + let result = parse_device_output(output); + assert!(result.is_err()); + } + + #[test] + fn test_parse_device_output_empty_devices() { + let output = r#" +Some header text +Available devices: + +Some footer text +"#; + let result = parse_device_output(output).unwrap(); + assert_eq!(result.len(), 0); + } + + #[test] + fn test_parse_device_output_mixed_valid_invalid() { + let output = r#" +Available devices: +Vulkan0: Intel(R) Arc(tm) A750 Graphics (DG2) (8128 MiB, 8128 MiB free) +InvalidLine: No memory info +CUDA0: NVIDIA GeForce RTX 4090 (24576 MiB, 24000 MiB free) +AnotherInvalid +"#; + + let result = parse_device_output(output).unwrap(); + assert_eq!(result.len(), 2); // Only valid lines should be parsed + + assert_eq!(result[0].id, "Vulkan0"); + assert_eq!(result[1].id, "CUDA0"); + } +} \ No newline at end of file diff --git a/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs b/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs index a62fb069a..bd335037b 100644 --- a/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs +++ b/src-tauri/plugins/tauri-plugin-llamacpp/src/path.rs @@ -1,147 +1,314 @@ -use std::path::PathBuf; - -use crate::error::{ErrorCode, LlamacppError, ServerResult}; - -#[cfg(windows)] -use std::os::windows::ffi::OsStrExt; - -#[cfg(windows)] -use std::ffi::OsStr; - -#[cfg(windows)] -use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW; - -/// Get Windows short path to avoid issues with spaces and special characters -#[cfg(windows)] -pub fn get_short_path>(path: P) -> Option { - let wide: Vec = OsStr::new(path.as_ref()) - .encode_wide() - .chain(Some(0)) - .collect(); - - let mut buffer = vec![0u16; 260]; - let len = unsafe { GetShortPathNameW(wide.as_ptr(), buffer.as_mut_ptr(), buffer.len() as u32) }; - - if len > 0 { - Some(String::from_utf16_lossy(&buffer[..len as usize])) - } else { - None - } -} - -/// Validate that a binary path exists and is accessible -pub fn validate_binary_path(backend_path: &str) -> ServerResult { - let server_path_buf = PathBuf::from(backend_path); - if !server_path_buf.exists() { - let err_msg = format!("Binary not found at {:?}", backend_path); - log::error!( - "Server binary not found at expected path: {:?}", - backend_path - ); - return Err(LlamacppError::new( - ErrorCode::BinaryNotFound, - "The llama.cpp server binary could not be found.".into(), - Some(err_msg), - ) - .into()); - } - Ok(server_path_buf) -} - -/// Validate model path exists and update args with platform-appropriate path format -pub fn validate_model_path(args: &mut Vec) -> ServerResult { - let model_path_index = args.iter().position(|arg| arg == "-m").ok_or_else(|| { - LlamacppError::new( - ErrorCode::ModelLoadFailed, - "Model path argument '-m' is missing.".into(), - None, - ) - })?; - - let model_path = args.get(model_path_index + 1).cloned().ok_or_else(|| { - LlamacppError::new( - ErrorCode::ModelLoadFailed, - "Model path was not provided after '-m' flag.".into(), - None, - ) - })?; - - let model_path_pb = PathBuf::from(&model_path); - if !model_path_pb.exists() { - let err_msg = format!( - "Invalid or inaccessible model path: {}", - model_path_pb.display() - ); - log::error!("{}", &err_msg); - return Err(LlamacppError::new( - ErrorCode::ModelFileNotFound, - "The specified model file does not exist or is not accessible.".into(), - Some(err_msg), - ) - .into()); - } - - // Update the path in args with appropriate format for the platform - #[cfg(windows)] - { - // use short path on Windows - if let Some(short) = get_short_path(&model_path_pb) { - args[model_path_index + 1] = short; - } else { - args[model_path_index + 1] = model_path_pb.display().to_string(); - } - } - #[cfg(not(windows))] - { - args[model_path_index + 1] = model_path_pb.display().to_string(); - } - - Ok(model_path_pb) -} - -/// Validate mmproj path exists and update args with platform-appropriate path format -pub fn validate_mmproj_path(args: &mut Vec) -> ServerResult> { - let mmproj_path_index = match args.iter().position(|arg| arg == "--mmproj") { - Some(index) => index, - None => return Ok(None), // mmproj is optional - }; - - let mmproj_path = args.get(mmproj_path_index + 1).cloned().ok_or_else(|| { - LlamacppError::new( - ErrorCode::ModelLoadFailed, - "Mmproj path was not provided after '--mmproj' flag.".into(), - None, - ) - })?; - - let mmproj_path_pb = PathBuf::from(&mmproj_path); - if !mmproj_path_pb.exists() { - let err_msg = format!( - "Invalid or inaccessible mmproj path: {}", - mmproj_path_pb.display() - ); - log::error!("{}", &err_msg); - return Err(LlamacppError::new( - ErrorCode::ModelFileNotFound, - "The specified mmproj file does not exist or is not accessible.".into(), - Some(err_msg), - ) - .into()); - } - - #[cfg(windows)] - { - // use short path on Windows - if let Some(short) = get_short_path(&mmproj_path_pb) { - args[mmproj_path_index + 1] = short; - } else { - args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string(); - } - } - #[cfg(not(windows))] - { - args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string(); - } - - Ok(Some(mmproj_path_pb)) -} +use std::path::PathBuf; + +use crate::error::{ErrorCode, LlamacppError, ServerResult}; + +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt; + +#[cfg(windows)] +use std::ffi::OsStr; + +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW; + +/// Get Windows short path to avoid issues with spaces and special characters +#[cfg(windows)] +pub fn get_short_path>(path: P) -> Option { + let wide: Vec = OsStr::new(path.as_ref()) + .encode_wide() + .chain(Some(0)) + .collect(); + + let mut buffer = vec![0u16; 260]; + let len = unsafe { GetShortPathNameW(wide.as_ptr(), buffer.as_mut_ptr(), buffer.len() as u32) }; + + if len > 0 { + Some(String::from_utf16_lossy(&buffer[..len as usize])) + } else { + None + } +} + +/// Validate that a binary path exists and is accessible +pub fn validate_binary_path(backend_path: &str) -> ServerResult { + let server_path_buf = PathBuf::from(backend_path); + if !server_path_buf.exists() { + let err_msg = format!("Binary not found at {:?}", backend_path); + log::error!( + "Server binary not found at expected path: {:?}", + backend_path + ); + return Err(LlamacppError::new( + ErrorCode::BinaryNotFound, + "The llama.cpp server binary could not be found.".into(), + Some(err_msg), + ) + .into()); + } + Ok(server_path_buf) +} + +/// Validate model path exists and update args with platform-appropriate path format +pub fn validate_model_path(args: &mut Vec) -> ServerResult { + let model_path_index = args.iter().position(|arg| arg == "-m").ok_or_else(|| { + LlamacppError::new( + ErrorCode::ModelLoadFailed, + "Model path argument '-m' is missing.".into(), + None, + ) + })?; + + let model_path = args.get(model_path_index + 1).cloned().ok_or_else(|| { + LlamacppError::new( + ErrorCode::ModelLoadFailed, + "Model path was not provided after '-m' flag.".into(), + None, + ) + })?; + + let model_path_pb = PathBuf::from(&model_path); + if !model_path_pb.exists() { + let err_msg = format!( + "Invalid or inaccessible model path: {}", + model_path_pb.display() + ); + log::error!("{}", &err_msg); + return Err(LlamacppError::new( + ErrorCode::ModelFileNotFound, + "The specified model file does not exist or is not accessible.".into(), + Some(err_msg), + ) + .into()); + } + + // Update the path in args with appropriate format for the platform + #[cfg(windows)] + { + // use short path on Windows + if let Some(short) = get_short_path(&model_path_pb) { + args[model_path_index + 1] = short; + } else { + args[model_path_index + 1] = model_path_pb.display().to_string(); + } + } + #[cfg(not(windows))] + { + args[model_path_index + 1] = model_path_pb.display().to_string(); + } + + Ok(model_path_pb) +} + +/// Validate mmproj path exists and update args with platform-appropriate path format +pub fn validate_mmproj_path(args: &mut Vec) -> ServerResult> { + let mmproj_path_index = match args.iter().position(|arg| arg == "--mmproj") { + Some(index) => index, + None => return Ok(None), // mmproj is optional + }; + + let mmproj_path = args.get(mmproj_path_index + 1).cloned().ok_or_else(|| { + LlamacppError::new( + ErrorCode::ModelLoadFailed, + "Mmproj path was not provided after '--mmproj' flag.".into(), + None, + ) + })?; + + let mmproj_path_pb = PathBuf::from(&mmproj_path); + if !mmproj_path_pb.exists() { + let err_msg = format!( + "Invalid or inaccessible mmproj path: {}", + mmproj_path_pb.display() + ); + log::error!("{}", &err_msg); + return Err(LlamacppError::new( + ErrorCode::ModelFileNotFound, + "The specified mmproj file does not exist or is not accessible.".into(), + Some(err_msg), + ) + .into()); + } + + #[cfg(windows)] + { + // use short path on Windows + if let Some(short) = get_short_path(&mmproj_path_pb) { + args[mmproj_path_index + 1] = short; + } else { + args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string(); + } + } + #[cfg(not(windows))] + { + args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string(); + } + + Ok(Some(mmproj_path_pb)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[test] + fn test_validate_binary_path_existing() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path().to_str().unwrap(); + + let result = validate_binary_path(path); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from(path)); + } + + #[test] + fn test_validate_binary_path_nonexistent() { + let nonexistent_path = "/tmp/definitely_does_not_exist_123456789"; + let result = validate_binary_path(nonexistent_path); + assert!(result.is_err()); + } + + #[test] + fn test_validate_model_path_valid() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path().to_str().unwrap(); + + let mut args = vec!["-m".to_string(), path.to_string(), "--verbose".to_string()]; + let result = validate_model_path(&mut args); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from(path)); + // Args should be updated with the path + #[cfg(windows)] + { + // On Windows, the path might be converted to short path format + // Just verify that the path in args[1] points to the same file + assert!(PathBuf::from(&args[1]).exists()); + } + #[cfg(not(windows))] + { + assert_eq!(args[1], temp_file.path().display().to_string()); + } + } + + #[test] + fn test_validate_model_path_missing_flag() { + let mut args = vec!["--verbose".to_string(), "value".to_string()]; + let result = validate_model_path(&mut args); + assert!(result.is_err()); + } + + #[test] + fn test_validate_model_path_missing_value() { + let mut args = vec!["-m".to_string()]; + let result = validate_model_path(&mut args); + assert!(result.is_err()); + } + + #[test] + fn test_validate_model_path_nonexistent_file() { + let nonexistent_path = "/tmp/nonexistent_model_123456789.gguf"; + let mut args = vec!["-m".to_string(), nonexistent_path.to_string()]; + let result = validate_model_path(&mut args); + assert!(result.is_err()); + } + + #[test] + fn test_validate_mmproj_path_valid() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path().to_str().unwrap(); + + let mut args = vec!["--mmproj".to_string(), path.to_string(), "--verbose".to_string()]; + let result = validate_mmproj_path(&mut args); + + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + // Args should be updated with the path + #[cfg(windows)] + { + // On Windows, the path might be converted to short path format + // Just verify that the path in args[1] points to the same file + assert!(PathBuf::from(&args[1]).exists()); + } + #[cfg(not(windows))] + { + assert_eq!(args[1], temp_file.path().display().to_string()); + } + } + + #[test] + fn test_validate_mmproj_path_missing() { + let mut args = vec!["--verbose".to_string(), "value".to_string()]; + let result = validate_mmproj_path(&mut args); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); // mmproj is optional + } + + #[test] + fn test_validate_mmproj_path_missing_value() { + let mut args = vec!["--mmproj".to_string()]; + let result = validate_mmproj_path(&mut args); + assert!(result.is_err()); + } + + #[test] + fn test_validate_mmproj_path_nonexistent_file() { + let nonexistent_path = "/tmp/nonexistent_mmproj_123456789.gguf"; + let mut args = vec!["--mmproj".to_string(), nonexistent_path.to_string()]; + let result = validate_mmproj_path(&mut args); + assert!(result.is_err()); + } + + #[cfg(windows)] + #[test] + fn test_get_short_path() { + // Test with a real path that should exist on Windows + use std::env; + if let Ok(temp_dir) = env::var("TEMP") { + let result = get_short_path(&temp_dir); + // Should return some short path or None (both are valid) + // We can't assert the exact value as it depends on the system + println!("Short path result: {:?}", result); + } + } + + #[test] + fn test_validate_model_path_multiple_m_flags() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path().to_str().unwrap(); + + // Multiple -m flags - should use the first one + let mut args = vec![ + "-m".to_string(), + path.to_string(), + "--verbose".to_string(), + "-m".to_string(), + "another_path".to_string() + ]; + let result = validate_model_path(&mut args); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), PathBuf::from(path)); + } + + #[test] + fn test_validate_mmproj_path_multiple_flags() { + let temp_file = NamedTempFile::new().unwrap(); + let path = temp_file.path().to_str().unwrap(); + + // Multiple --mmproj flags - should use the first one + let mut args = vec![ + "--mmproj".to_string(), + path.to_string(), + "--verbose".to_string(), + "--mmproj".to_string(), + "another_path".to_string() + ]; + let result = validate_mmproj_path(&mut args); + + assert!(result.is_ok()); + let result_path = result.unwrap(); + assert!(result_path.is_some()); + assert_eq!(result_path.unwrap(), PathBuf::from(path)); + } +} diff --git a/src-tauri/utils/Cargo.toml b/src-tauri/utils/Cargo.toml index 09fc121e4..071d39eeb 100644 --- a/src-tauri/utils/Cargo.toml +++ b/src-tauri/utils/Cargo.toml @@ -12,10 +12,13 @@ reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" -tokio = { version = "1", features = ["process"] } +tokio = { version = "1", features = ["process", "fs", "macros", "rt"] } tokio-util = "0.7.14" url = "2.5" +[dev-dependencies] +tempfile = "3.0" + [features] default = [] logging = ["log"] diff --git a/src-tauri/utils/src/crypto.rs b/src-tauri/utils/src/crypto.rs index 379b10290..b5d4963f6 100644 --- a/src-tauri/utils/src/crypto.rs +++ b/src-tauri/utils/src/crypto.rs @@ -84,3 +84,108 @@ pub async fn compute_file_sha256_with_cancellation( log::debug!("Hash computation completed for {} bytes", total_read); Ok(hash_hex) } + +#[cfg(test)] +mod tests { + use super::*; + use tokio_util::sync::CancellationToken; + + #[test] + fn test_generate_app_token() { + let token1 = generate_app_token(); + let token2 = generate_app_token(); + + // Should be 32 characters long + assert_eq!(token1.len(), 32); + assert_eq!(token2.len(), 32); + + // Should be different each time + assert_ne!(token1, token2); + + // Should only contain alphanumeric characters + assert!(token1.chars().all(|c| c.is_alphanumeric())); + assert!(token2.chars().all(|c| c.is_alphanumeric())); + } + + #[test] + fn test_generate_api_key() { + let model_id = "test-model".to_string(); + let api_secret = "test-secret".to_string(); + + let key1 = generate_api_key(model_id.clone(), api_secret.clone()).unwrap(); + let key2 = generate_api_key(model_id.clone(), api_secret.clone()).unwrap(); + + // Should generate same key for same inputs + assert_eq!(key1, key2); + + // Should be base64 encoded (and thus contain base64 characters) + assert!(key1.chars().all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')); + + // Different model_id should produce different key + let different_key = generate_api_key("different-model".to_string(), api_secret).unwrap(); + assert_ne!(key1, different_key); + } + + #[test] + fn test_generate_api_key_empty_inputs() { + let result = generate_api_key("".to_string(), "secret".to_string()); + assert!(result.is_ok()); // Should still work with empty model_id + + let result = generate_api_key("model".to_string(), "".to_string()); + assert!(result.is_ok()); // Should still work with empty secret + } + + #[tokio::test] + async fn test_compute_file_sha256_with_cancellation() { + use std::io::Write; + use tempfile::NamedTempFile; + + // Create a temporary file with known content + let mut temp_file = NamedTempFile::new().unwrap(); + let test_content = b"Hello, World!"; + temp_file.write_all(test_content).unwrap(); + temp_file.flush().unwrap(); + + let token = CancellationToken::new(); + + // Compute hash of the file + let hash = compute_file_sha256_with_cancellation(temp_file.path(), &token).await.unwrap(); + + // Verify it's a valid hex string + assert_eq!(hash.len(), 64); // SHA256 is 256 bits = 64 hex chars + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + + // Verify it matches expected SHA256 of "Hello, World!" + let expected = "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"; + assert_eq!(hash, expected); + } + + #[tokio::test] + async fn test_compute_file_sha256_cancellation() { + use std::io::Write; + use tempfile::NamedTempFile; + + // Create a temporary file + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(b"test content").unwrap(); + temp_file.flush().unwrap(); + + let token = CancellationToken::new(); + token.cancel(); // Cancel immediately + + // Should return cancellation error + let result = compute_file_sha256_with_cancellation(temp_file.path(), &token).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("cancelled")); + } + + #[tokio::test] + async fn test_compute_file_sha256_nonexistent_file() { + let token = CancellationToken::new(); + let nonexistent_path = Path::new("/nonexistent/file.txt"); + + let result = compute_file_sha256_with_cancellation(nonexistent_path, &token).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to open file for hashing")); + } +} \ No newline at end of file diff --git a/src-tauri/utils/src/math.rs b/src-tauri/utils/src/math.rs index cdda4d058..05de74586 100644 --- a/src-tauri/utils/src/math.rs +++ b/src-tauri/utils/src/math.rs @@ -48,3 +48,86 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 { final_delay } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_exponential_backoff_delay_basic() { + let delay1 = calculate_exponential_backoff_delay(1); + let delay2 = calculate_exponential_backoff_delay(2); + let delay3 = calculate_exponential_backoff_delay(3); + + // First attempt should be around base delay (1000ms) ± jitter + assert!(delay1 >= 100 && delay1 <= 2000); + + // Second attempt should be roughly double + assert!(delay2 >= 1000 && delay2 <= 4000); + + // Third attempt should be roughly quadruple + assert!(delay3 >= 2000 && delay3 <= 6000); + + // Generally increasing pattern + assert!(delay1 < delay3); + } + + #[test] + fn test_calculate_exponential_backoff_delay_max_cap() { + // Very high attempt numbers should be capped at MAX_RESTART_DELAY_MS + let high_attempt_delay = calculate_exponential_backoff_delay(100); + assert!(high_attempt_delay <= MCP_MAX_RESTART_DELAY_MS); + assert!(high_attempt_delay >= 100); // Minimum delay + } + + #[test] + fn test_calculate_exponential_backoff_delay_minimum() { + // Even with jitter, should never go below minimum + for attempt in 1..=10 { + let delay = calculate_exponential_backoff_delay(attempt); + assert!(delay >= 100, "Delay {} for attempt {} is below minimum", delay, attempt); + } + } + + #[test] + fn test_calculate_exponential_backoff_delay_deterministic() { + // Same attempt number should produce same delay (deterministic jitter) + let delay1_a = calculate_exponential_backoff_delay(5); + let delay1_b = calculate_exponential_backoff_delay(5); + assert_eq!(delay1_a, delay1_b); + + let delay2_a = calculate_exponential_backoff_delay(10); + let delay2_b = calculate_exponential_backoff_delay(10); + assert_eq!(delay2_a, delay2_b); + } + + #[test] + fn test_calculate_exponential_backoff_delay_progression() { + // Test the general progression pattern + let mut delays = Vec::new(); + for attempt in 1..=8 { + delays.push(calculate_exponential_backoff_delay(attempt)); + } + + // Should not exceed maximum + for delay in &delays { + assert!(*delay <= MCP_MAX_RESTART_DELAY_MS); + } + + // Earlier attempts should generally be smaller than later ones + // (allowing some variance due to jitter) + assert!(delays[0] < delays[6]); // 1st vs 7th attempt + assert!(delays[1] < delays[7]); // 2nd vs 8th attempt + } + + #[test] + fn test_constants() { + // Verify our constants are reasonable + assert_eq!(MCP_BASE_RESTART_DELAY_MS, 1000); + assert_eq!(MCP_MAX_RESTART_DELAY_MS, 30000); + assert_eq!(MCP_BACKOFF_MULTIPLIER, 2.0); + + // Max should be greater than base + assert!(MCP_MAX_RESTART_DELAY_MS > MCP_BASE_RESTART_DELAY_MS); + } +} diff --git a/src-tauri/utils/src/string.rs b/src-tauri/utils/src/string.rs index b71dbaaff..a31611c07 100644 --- a/src-tauri/utils/src/string.rs +++ b/src-tauri/utils/src/string.rs @@ -73,3 +73,130 @@ pub fn is_memory_pattern(content: &str) -> bool { && part.contains("MiB") }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_uuid() { + let uuid_bytes = [ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 + ]; + + let uuid_string = parse_uuid(&uuid_bytes); + assert_eq!(uuid_string, "12345678-9abc-def0-1122-334455667788"); + } + + #[test] + fn test_parse_uuid_zeros() { + let zero_bytes = [0; 16]; + let uuid_string = parse_uuid(&zero_bytes); + assert_eq!(uuid_string, "00000000-0000-0000-0000-000000000000"); + } + + #[test] + fn test_parse_uuid_max_values() { + let max_bytes = [0xff; 16]; + let uuid_string = parse_uuid(&max_bytes); + assert_eq!(uuid_string, "ffffffff-ffff-ffff-ffff-ffffffffffff"); + } + + #[test] + fn test_parse_c_string() { + let c_string = [b'H' as i8, b'e' as i8, b'l' as i8, b'l' as i8, b'o' as i8, 0, b'W' as i8]; + let result = parse_c_string(&c_string); + assert_eq!(result, "Hello"); + } + + #[test] + fn test_parse_c_string_empty() { + let empty_c_string = [0]; + let result = parse_c_string(&empty_c_string); + assert_eq!(result, ""); + } + + #[test] + fn test_parse_c_string_no_null_terminator() { + let no_null = [b'T' as i8, b'e' as i8, b's' as i8, b't' as i8]; + let result = parse_c_string(&no_null); + assert_eq!(result, "Test"); + } + + #[test] + fn test_parse_c_string_with_negative_values() { + let with_negative = [-1, b'A' as i8, b'B' as i8, 0]; + let result = parse_c_string(&with_negative); + // Should convert negative to unsigned byte + assert!(result.len() > 0); + assert!(result.contains('A')); + assert!(result.contains('B')); + } + + #[test] + fn test_err_to_string() { + let error_msg = "Something went wrong"; + let result = err_to_string(error_msg); + assert_eq!(result, "Error: Something went wrong"); + + let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found"); + let result = err_to_string(io_error); + assert!(result.starts_with("Error: ")); + assert!(result.contains("File not found")); + } + + #[test] + fn test_is_memory_pattern_valid() { + assert!(is_memory_pattern("8128 MiB, 8128 MiB free")); + assert!(is_memory_pattern("1024 MiB, 512 MiB free")); + assert!(is_memory_pattern("16384 MiB, 12000 MiB free")); + assert!(is_memory_pattern("0 MiB, 0 MiB free")); + } + + #[test] + fn test_is_memory_pattern_invalid() { + assert!(!is_memory_pattern("8128 MB, 8128 MB free")); // Wrong unit + assert!(!is_memory_pattern("8128 MiB 8128 MiB free")); // Missing comma + assert!(!is_memory_pattern("8128 MiB, 8128 MiB used")); // Wrong second part + assert!(!is_memory_pattern("not_a_number MiB, 8128 MiB free")); // Invalid number + assert!(!is_memory_pattern("8128 MiB")); // Missing second part + assert!(!is_memory_pattern("")); // Empty string + assert!(!is_memory_pattern("8128 MiB, free")); // Missing number in second part + } + + #[test] + fn test_find_memory_pattern() { + let text = "Loading model... (8128 MiB, 4096 MiB free) completed"; + let result = find_memory_pattern(text); + assert!(result.is_some()); + let (start_idx, content) = result.unwrap(); + assert!(start_idx > 0); + assert_eq!(content, "8128 MiB, 4096 MiB free"); + } + + #[test] + fn test_find_memory_pattern_multiple_parentheses() { + let text = "Start (not memory) then (1024 MiB, 512 MiB free) and (2048 MiB, 1024 MiB free) end"; + let result = find_memory_pattern(text); + assert!(result.is_some()); + let (_, content) = result.unwrap(); + // Should return the LAST valid memory pattern + assert_eq!(content, "2048 MiB, 1024 MiB free"); + } + + #[test] + fn test_find_memory_pattern_no_match() { + let text = "No memory info here"; + assert!(find_memory_pattern(text).is_none()); + + let text_with_invalid = "Some text (invalid memory info) here"; + assert!(find_memory_pattern(text_with_invalid).is_none()); + } + + #[test] + fn test_find_memory_pattern_unclosed_parenthesis() { + let text = "Unclosed (8128 MiB, 4096 MiB free"; + assert!(find_memory_pattern(text).is_none()); + } +} diff --git a/web-app/src/services/__tests__/serviceHub.integration.test.ts b/web-app/src/services/__tests__/serviceHub.integration.test.ts index 5af5ee99e..5b89347a1 100644 --- a/web-app/src/services/__tests__/serviceHub.integration.test.ts +++ b/web-app/src/services/__tests__/serviceHub.integration.test.ts @@ -1,91 +1,216 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { initializeServiceHub, type ServiceHub } from '../index' -import { isPlatformTauri } from '@/lib/platform/utils' - -// Mock platform detection -vi.mock('@/lib/platform/utils', () => ({ - isPlatformTauri: vi.fn().mockReturnValue(false) -})) - -// Mock @jan/extensions-web to return empty extensions for testing -vi.mock('@jan/extensions-web', () => ({ - WEB_EXTENSIONS: {} -})) - -// Mock console to avoid noise in tests -vi.spyOn(console, 'log').mockImplementation(() => {}) -vi.spyOn(console, 'error').mockImplementation(() => {}) - -describe('ServiceHub Integration Tests', () => { - let serviceHub: ServiceHub - - beforeEach(async () => { - vi.clearAllMocks() - serviceHub = await initializeServiceHub() - }) - - describe('ServiceHub Initialization', () => { - it('should initialize with web services when not on Tauri', async () => { - vi.mocked(isPlatformTauri).mockReturnValue(false) - - serviceHub = await initializeServiceHub() - - expect(serviceHub).toBeDefined() - expect(console.log).toHaveBeenCalledWith( - 'Initializing service hub for platform:', - 'Web' - ) - }) - - it('should initialize with Tauri services when on Tauri', async () => { - vi.mocked(isPlatformTauri).mockReturnValue(true) - - serviceHub = await initializeServiceHub() - - expect(serviceHub).toBeDefined() - expect(console.log).toHaveBeenCalledWith( - 'Initializing service hub for platform:', - 'Tauri' - ) - }) - }) - - describe('Service Access', () => { - it('should provide access to all required services', () => { - const services = [ - 'theme', 'window', 'events', 'hardware', 'app', 'analytic', - 'messages', 'mcp', 'threads', 'providers', 'models', 'assistants', - 'dialog', 'opener', 'updater', 'path', 'core', 'deeplink' - ] - - services.forEach(serviceName => { - expect(typeof serviceHub[serviceName as keyof ServiceHub]).toBe('function') - expect(serviceHub[serviceName as keyof ServiceHub]()).toBeDefined() - }) - }) - - it('should return same service instance on multiple calls', () => { - const themeService1 = serviceHub.theme() - const themeService2 = serviceHub.theme() - - expect(themeService1).toBe(themeService2) - }) - }) - - describe('Basic Service Functionality', () => { - it('should have working theme service', () => { - const theme = serviceHub.theme() - - expect(typeof theme.setTheme).toBe('function') - expect(typeof theme.getCurrentWindow).toBe('function') - }) - - it('should have working events service', () => { - const events = serviceHub.events() - - expect(typeof events.emit).toBe('function') - expect(typeof events.listen).toBe('function') - }) - - }) +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { initializeServiceHub, type ServiceHub } from '../index' +import { isPlatformTauri } from '@/lib/platform/utils' + +// Mock platform detection +vi.mock('@/lib/platform/utils', () => ({ + isPlatformTauri: vi.fn().mockReturnValue(false) +})) + +// Mock @jan/extensions-web to return empty extensions for testing +vi.mock('@jan/extensions-web', () => ({ + WEB_EXTENSIONS: {} +})) + +// Mock @janhq/core EngineManager to prevent initialization issues +vi.mock('@janhq/core', () => ({ + EngineManager: { + instance: vi.fn(() => ({ + engines: new Map() + })) + } +})) + +// Mock token.js to avoid initialization issues +vi.mock('token.js', () => ({ + models: {} +})) + +// Mock ExtensionManager to avoid initialization issues +vi.mock('@/lib/extension', () => ({ + ExtensionManager: { + getInstance: vi.fn(() => ({ + getEngine: vi.fn() + })) + } +})) + +// Mock dynamic imports for web services +vi.mock('../theme/web', () => ({ + WebThemeService: vi.fn().mockImplementation(() => ({ + setTheme: vi.fn(), + getCurrentWindow: vi.fn() + })) +})) + +vi.mock('../app/web', () => ({ + WebAppService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../path/web', () => ({ + WebPathService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../core/web', () => ({ + WebCoreService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../dialog/web', () => ({ + WebDialogService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../events/web', () => ({ + WebEventsService: vi.fn().mockImplementation(() => ({ + emit: vi.fn(), + listen: vi.fn() + })) +})) + +vi.mock('../window/web', () => ({ + WebWindowService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../deeplink/web', () => ({ + WebDeepLinkService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../providers/web', () => ({ + WebProvidersService: vi.fn().mockImplementation(() => ({})) +})) + +// Mock dynamic imports for Tauri services +vi.mock('../theme/tauri', () => ({ + TauriThemeService: vi.fn().mockImplementation(() => ({ + setTheme: vi.fn(), + getCurrentWindow: vi.fn() + })) +})) + +vi.mock('../window/tauri', () => ({ + TauriWindowService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../events/tauri', () => ({ + TauriEventsService: vi.fn().mockImplementation(() => ({ + emit: vi.fn(), + listen: vi.fn() + })) +})) + +vi.mock('../hardware/tauri', () => ({ + TauriHardwareService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../app/tauri', () => ({ + TauriAppService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../mcp/tauri', () => ({ + TauriMCPService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../providers/tauri', () => ({ + TauriProvidersService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../dialog/tauri', () => ({ + TauriDialogService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../opener/tauri', () => ({ + TauriOpenerService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../updater/tauri', () => ({ + TauriUpdaterService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../path/tauri', () => ({ + TauriPathService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../core/tauri', () => ({ + TauriCoreService: vi.fn().mockImplementation(() => ({})) +})) + +vi.mock('../deeplink/tauri', () => ({ + TauriDeepLinkService: vi.fn().mockImplementation(() => ({})) +})) + +// Mock console to avoid noise in tests +vi.spyOn(console, 'log').mockImplementation(() => {}) +vi.spyOn(console, 'error').mockImplementation(() => {}) + +describe('ServiceHub Integration Tests', () => { + let serviceHub: ServiceHub + + beforeEach(async () => { + vi.clearAllMocks() + serviceHub = await initializeServiceHub() + }) + + describe('ServiceHub Initialization', () => { + it('should initialize with web services when not on Tauri', async () => { + vi.mocked(isPlatformTauri).mockReturnValue(false) + + serviceHub = await initializeServiceHub() + + expect(serviceHub).toBeDefined() + expect(console.log).toHaveBeenCalledWith( + 'Initializing service hub for platform:', + 'Web' + ) + }) + + it('should initialize with Tauri services when on Tauri', async () => { + vi.mocked(isPlatformTauri).mockReturnValue(true) + + serviceHub = await initializeServiceHub() + + expect(serviceHub).toBeDefined() + expect(console.log).toHaveBeenCalledWith( + 'Initializing service hub for platform:', + 'Tauri' + ) + }) + }) + + describe('Service Access', () => { + it('should provide access to all required services', () => { + const services = [ + 'theme', 'window', 'events', 'hardware', 'app', 'analytic', + 'messages', 'mcp', 'threads', 'providers', 'models', 'assistants', + 'dialog', 'opener', 'updater', 'path', 'core', 'deeplink' + ] + + services.forEach(serviceName => { + expect(typeof serviceHub[serviceName as keyof ServiceHub]).toBe('function') + expect(serviceHub[serviceName as keyof ServiceHub]()).toBeDefined() + }) + }) + + it('should return same service instance on multiple calls', () => { + const themeService1 = serviceHub.theme() + const themeService2 = serviceHub.theme() + + expect(themeService1).toBe(themeService2) + }) + }) + + describe('Basic Service Functionality', () => { + it('should have working theme service', () => { + const theme = serviceHub.theme() + + expect(typeof theme.setTheme).toBe('function') + expect(typeof theme.getCurrentWindow).toBe('function') + }) + + it('should have working events service', () => { + const events = serviceHub.events() + + expect(typeof events.emit).toBe('function') + expect(typeof events.listen).toBe('function') + }) + + }) }) \ No newline at end of file diff --git a/web-app/src/services/__tests__/web-specific.test.ts b/web-app/src/services/__tests__/web-specific.test.ts index b0f5e1dc3..135b48c1c 100644 --- a/web-app/src/services/__tests__/web-specific.test.ts +++ b/web-app/src/services/__tests__/web-specific.test.ts @@ -40,6 +40,25 @@ describe('Web-Specific Service Tests', () => { describe('WebProvidersService', () => { it('should use browser fetch for API calls', async () => { + // Mock the dependencies before importing + vi.mock('token.js', () => ({ + models: {} + })) + vi.mock('@/lib/extension', () => ({ + ExtensionManager: { + getInstance: vi.fn(() => ({ + getEngine: vi.fn() + })) + } + })) + vi.mock('@janhq/core', () => ({ + EngineManager: { + instance: vi.fn(() => ({ + engines: new Map() + })) + } + })) + const { WebProvidersService } = await import('../providers/web') const mockResponse = { ok: true, @@ -66,7 +85,7 @@ describe('Web-Specific Service Tests', () => { }) ) expect(models).toEqual(['gpt-4']) - }) + }, 10000) // Increase timeout to 10 seconds }) describe('WebAppService', () => {