feat: improve testing (#6395)
* add more test rust test * fix servicehub test * fix tauri failing on windows
This commit is contained in:
parent
dbaf563e88
commit
5cd81bc6e8
1
Makefile
1
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/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-hardware/Cargo.toml
|
||||||
cargo test --manifest-path src-tauri/plugins/tauri-plugin-llamacpp/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
|
# Builds and publishes the app
|
||||||
build-and-publish: install-and-build
|
build-and-publish: install-and-build
|
||||||
|
|||||||
54
mise.toml
54
mise.toml
@ -138,11 +138,56 @@ description = "Run linting (matches Makefile)"
|
|||||||
depends = ["build-extensions"]
|
depends = ["build-extensions"]
|
||||||
run = "yarn lint"
|
run = "yarn lint"
|
||||||
|
|
||||||
[tasks.test]
|
# ============================================================================
|
||||||
description = "Run test suite (matches Makefile)"
|
# RUST TEST COMPONENTS
|
||||||
depends = ["lint"]
|
# ============================================================================
|
||||||
|
|
||||||
|
[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"
|
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
|
# PARALLEL-FRIENDLY QUALITY ASSURANCE TASKS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -155,8 +200,7 @@ hide = true
|
|||||||
|
|
||||||
[tasks.test-only]
|
[tasks.test-only]
|
||||||
description = "Run tests only (parallel-friendly)"
|
description = "Run tests only (parallel-friendly)"
|
||||||
depends = ["build-extensions"]
|
depends = ["build-extensions", "test-js", "test-rust"]
|
||||||
run = "yarn test"
|
|
||||||
hide = true
|
hide = true
|
||||||
|
|
||||||
[tasks.qa-parallel]
|
[tasks.qa-parallel]
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "jan-app",
|
"name": "jan-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
"core",
|
"core",
|
||||||
|
|||||||
@ -33,5 +33,8 @@ windows-sys = { version = "0.60.2", features = ["Win32_Storage_FileSystem"] }
|
|||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
nix = { version = "=0.30.1", features = ["signal", "process"] }
|
nix = { version = "=0.30.1", features = ["signal", "process"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.0"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-plugin = { version = "2.3.1", features = ["build"] }
|
tauri-plugin = { version = "2.3.1", features = ["build"] }
|
||||||
|
|||||||
@ -228,3 +228,186 @@ fn parse_memory_value(mem_str: &str) -> ServerResult<i32> {
|
|||||||
.into()
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,147 +1,314 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::error::{ErrorCode, LlamacppError, ServerResult};
|
use crate::error::{ErrorCode, LlamacppError, ServerResult};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW;
|
use windows_sys::Win32::Storage::FileSystem::GetShortPathNameW;
|
||||||
|
|
||||||
/// Get Windows short path to avoid issues with spaces and special characters
|
/// Get Windows short path to avoid issues with spaces and special characters
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn get_short_path<P: AsRef<std::path::Path>>(path: P) -> Option<String> {
|
pub fn get_short_path<P: AsRef<std::path::Path>>(path: P) -> Option<String> {
|
||||||
let wide: Vec<u16> = OsStr::new(path.as_ref())
|
let wide: Vec<u16> = OsStr::new(path.as_ref())
|
||||||
.encode_wide()
|
.encode_wide()
|
||||||
.chain(Some(0))
|
.chain(Some(0))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut buffer = vec![0u16; 260];
|
let mut buffer = vec![0u16; 260];
|
||||||
let len = unsafe { GetShortPathNameW(wide.as_ptr(), buffer.as_mut_ptr(), buffer.len() as u32) };
|
let len = unsafe { GetShortPathNameW(wide.as_ptr(), buffer.as_mut_ptr(), buffer.len() as u32) };
|
||||||
|
|
||||||
if len > 0 {
|
if len > 0 {
|
||||||
Some(String::from_utf16_lossy(&buffer[..len as usize]))
|
Some(String::from_utf16_lossy(&buffer[..len as usize]))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate that a binary path exists and is accessible
|
/// Validate that a binary path exists and is accessible
|
||||||
pub fn validate_binary_path(backend_path: &str) -> ServerResult<PathBuf> {
|
pub fn validate_binary_path(backend_path: &str) -> ServerResult<PathBuf> {
|
||||||
let server_path_buf = PathBuf::from(backend_path);
|
let server_path_buf = PathBuf::from(backend_path);
|
||||||
if !server_path_buf.exists() {
|
if !server_path_buf.exists() {
|
||||||
let err_msg = format!("Binary not found at {:?}", backend_path);
|
let err_msg = format!("Binary not found at {:?}", backend_path);
|
||||||
log::error!(
|
log::error!(
|
||||||
"Server binary not found at expected path: {:?}",
|
"Server binary not found at expected path: {:?}",
|
||||||
backend_path
|
backend_path
|
||||||
);
|
);
|
||||||
return Err(LlamacppError::new(
|
return Err(LlamacppError::new(
|
||||||
ErrorCode::BinaryNotFound,
|
ErrorCode::BinaryNotFound,
|
||||||
"The llama.cpp server binary could not be found.".into(),
|
"The llama.cpp server binary could not be found.".into(),
|
||||||
Some(err_msg),
|
Some(err_msg),
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
Ok(server_path_buf)
|
Ok(server_path_buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate model path exists and update args with platform-appropriate path format
|
/// Validate model path exists and update args with platform-appropriate path format
|
||||||
pub fn validate_model_path(args: &mut Vec<String>) -> ServerResult<PathBuf> {
|
pub fn validate_model_path(args: &mut Vec<String>) -> ServerResult<PathBuf> {
|
||||||
let model_path_index = args.iter().position(|arg| arg == "-m").ok_or_else(|| {
|
let model_path_index = args.iter().position(|arg| arg == "-m").ok_or_else(|| {
|
||||||
LlamacppError::new(
|
LlamacppError::new(
|
||||||
ErrorCode::ModelLoadFailed,
|
ErrorCode::ModelLoadFailed,
|
||||||
"Model path argument '-m' is missing.".into(),
|
"Model path argument '-m' is missing.".into(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let model_path = args.get(model_path_index + 1).cloned().ok_or_else(|| {
|
let model_path = args.get(model_path_index + 1).cloned().ok_or_else(|| {
|
||||||
LlamacppError::new(
|
LlamacppError::new(
|
||||||
ErrorCode::ModelLoadFailed,
|
ErrorCode::ModelLoadFailed,
|
||||||
"Model path was not provided after '-m' flag.".into(),
|
"Model path was not provided after '-m' flag.".into(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let model_path_pb = PathBuf::from(&model_path);
|
let model_path_pb = PathBuf::from(&model_path);
|
||||||
if !model_path_pb.exists() {
|
if !model_path_pb.exists() {
|
||||||
let err_msg = format!(
|
let err_msg = format!(
|
||||||
"Invalid or inaccessible model path: {}",
|
"Invalid or inaccessible model path: {}",
|
||||||
model_path_pb.display()
|
model_path_pb.display()
|
||||||
);
|
);
|
||||||
log::error!("{}", &err_msg);
|
log::error!("{}", &err_msg);
|
||||||
return Err(LlamacppError::new(
|
return Err(LlamacppError::new(
|
||||||
ErrorCode::ModelFileNotFound,
|
ErrorCode::ModelFileNotFound,
|
||||||
"The specified model file does not exist or is not accessible.".into(),
|
"The specified model file does not exist or is not accessible.".into(),
|
||||||
Some(err_msg),
|
Some(err_msg),
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the path in args with appropriate format for the platform
|
// Update the path in args with appropriate format for the platform
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// use short path on Windows
|
// use short path on Windows
|
||||||
if let Some(short) = get_short_path(&model_path_pb) {
|
if let Some(short) = get_short_path(&model_path_pb) {
|
||||||
args[model_path_index + 1] = short;
|
args[model_path_index + 1] = short;
|
||||||
} else {
|
} else {
|
||||||
args[model_path_index + 1] = model_path_pb.display().to_string();
|
args[model_path_index + 1] = model_path_pb.display().to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
args[model_path_index + 1] = model_path_pb.display().to_string();
|
args[model_path_index + 1] = model_path_pb.display().to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(model_path_pb)
|
Ok(model_path_pb)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate mmproj path exists and update args with platform-appropriate path format
|
/// Validate mmproj path exists and update args with platform-appropriate path format
|
||||||
pub fn validate_mmproj_path(args: &mut Vec<String>) -> ServerResult<Option<PathBuf>> {
|
pub fn validate_mmproj_path(args: &mut Vec<String>) -> ServerResult<Option<PathBuf>> {
|
||||||
let mmproj_path_index = match args.iter().position(|arg| arg == "--mmproj") {
|
let mmproj_path_index = match args.iter().position(|arg| arg == "--mmproj") {
|
||||||
Some(index) => index,
|
Some(index) => index,
|
||||||
None => return Ok(None), // mmproj is optional
|
None => return Ok(None), // mmproj is optional
|
||||||
};
|
};
|
||||||
|
|
||||||
let mmproj_path = args.get(mmproj_path_index + 1).cloned().ok_or_else(|| {
|
let mmproj_path = args.get(mmproj_path_index + 1).cloned().ok_or_else(|| {
|
||||||
LlamacppError::new(
|
LlamacppError::new(
|
||||||
ErrorCode::ModelLoadFailed,
|
ErrorCode::ModelLoadFailed,
|
||||||
"Mmproj path was not provided after '--mmproj' flag.".into(),
|
"Mmproj path was not provided after '--mmproj' flag.".into(),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mmproj_path_pb = PathBuf::from(&mmproj_path);
|
let mmproj_path_pb = PathBuf::from(&mmproj_path);
|
||||||
if !mmproj_path_pb.exists() {
|
if !mmproj_path_pb.exists() {
|
||||||
let err_msg = format!(
|
let err_msg = format!(
|
||||||
"Invalid or inaccessible mmproj path: {}",
|
"Invalid or inaccessible mmproj path: {}",
|
||||||
mmproj_path_pb.display()
|
mmproj_path_pb.display()
|
||||||
);
|
);
|
||||||
log::error!("{}", &err_msg);
|
log::error!("{}", &err_msg);
|
||||||
return Err(LlamacppError::new(
|
return Err(LlamacppError::new(
|
||||||
ErrorCode::ModelFileNotFound,
|
ErrorCode::ModelFileNotFound,
|
||||||
"The specified mmproj file does not exist or is not accessible.".into(),
|
"The specified mmproj file does not exist or is not accessible.".into(),
|
||||||
Some(err_msg),
|
Some(err_msg),
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// use short path on Windows
|
// use short path on Windows
|
||||||
if let Some(short) = get_short_path(&mmproj_path_pb) {
|
if let Some(short) = get_short_path(&mmproj_path_pb) {
|
||||||
args[mmproj_path_index + 1] = short;
|
args[mmproj_path_index + 1] = short;
|
||||||
} else {
|
} else {
|
||||||
args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string();
|
args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string();
|
args[mmproj_path_index + 1] = mmproj_path_pb.display().to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Some(mmproj_path_pb))
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -12,10 +12,13 @@ reqwest = { version = "0.11", features = ["json"] }
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
tokio = { version = "1", features = ["process"] }
|
tokio = { version = "1", features = ["process", "fs", "macros", "rt"] }
|
||||||
tokio-util = "0.7.14"
|
tokio-util = "0.7.14"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
logging = ["log"]
|
logging = ["log"]
|
||||||
|
|||||||
@ -84,3 +84,108 @@ pub async fn compute_file_sha256_with_cancellation(
|
|||||||
log::debug!("Hash computation completed for {} bytes", total_read);
|
log::debug!("Hash computation completed for {} bytes", total_read);
|
||||||
Ok(hash_hex)
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,3 +48,86 @@ pub fn calculate_exponential_backoff_delay(attempt: u32) -> u64 {
|
|||||||
|
|
||||||
final_delay
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -73,3 +73,130 @@ pub fn is_memory_pattern(content: &str) -> bool {
|
|||||||
&& part.contains("MiB")
|
&& 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,91 +1,216 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { initializeServiceHub, type ServiceHub } from '../index'
|
import { initializeServiceHub, type ServiceHub } from '../index'
|
||||||
import { isPlatformTauri } from '@/lib/platform/utils'
|
import { isPlatformTauri } from '@/lib/platform/utils'
|
||||||
|
|
||||||
// Mock platform detection
|
// Mock platform detection
|
||||||
vi.mock('@/lib/platform/utils', () => ({
|
vi.mock('@/lib/platform/utils', () => ({
|
||||||
isPlatformTauri: vi.fn().mockReturnValue(false)
|
isPlatformTauri: vi.fn().mockReturnValue(false)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock @jan/extensions-web to return empty extensions for testing
|
// Mock @jan/extensions-web to return empty extensions for testing
|
||||||
vi.mock('@jan/extensions-web', () => ({
|
vi.mock('@jan/extensions-web', () => ({
|
||||||
WEB_EXTENSIONS: {}
|
WEB_EXTENSIONS: {}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock console to avoid noise in tests
|
// Mock @janhq/core EngineManager to prevent initialization issues
|
||||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
vi.mock('@janhq/core', () => ({
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
EngineManager: {
|
||||||
|
instance: vi.fn(() => ({
|
||||||
describe('ServiceHub Integration Tests', () => {
|
engines: new Map()
|
||||||
let serviceHub: ServiceHub
|
}))
|
||||||
|
}
|
||||||
beforeEach(async () => {
|
}))
|
||||||
vi.clearAllMocks()
|
|
||||||
serviceHub = await initializeServiceHub()
|
// Mock token.js to avoid initialization issues
|
||||||
})
|
vi.mock('token.js', () => ({
|
||||||
|
models: {}
|
||||||
describe('ServiceHub Initialization', () => {
|
}))
|
||||||
it('should initialize with web services when not on Tauri', async () => {
|
|
||||||
vi.mocked(isPlatformTauri).mockReturnValue(false)
|
// Mock ExtensionManager to avoid initialization issues
|
||||||
|
vi.mock('@/lib/extension', () => ({
|
||||||
serviceHub = await initializeServiceHub()
|
ExtensionManager: {
|
||||||
|
getInstance: vi.fn(() => ({
|
||||||
expect(serviceHub).toBeDefined()
|
getEngine: vi.fn()
|
||||||
expect(console.log).toHaveBeenCalledWith(
|
}))
|
||||||
'Initializing service hub for platform:',
|
}
|
||||||
'Web'
|
}))
|
||||||
)
|
|
||||||
})
|
// Mock dynamic imports for web services
|
||||||
|
vi.mock('../theme/web', () => ({
|
||||||
it('should initialize with Tauri services when on Tauri', async () => {
|
WebThemeService: vi.fn().mockImplementation(() => ({
|
||||||
vi.mocked(isPlatformTauri).mockReturnValue(true)
|
setTheme: vi.fn(),
|
||||||
|
getCurrentWindow: vi.fn()
|
||||||
serviceHub = await initializeServiceHub()
|
}))
|
||||||
|
}))
|
||||||
expect(serviceHub).toBeDefined()
|
|
||||||
expect(console.log).toHaveBeenCalledWith(
|
vi.mock('../app/web', () => ({
|
||||||
'Initializing service hub for platform:',
|
WebAppService: vi.fn().mockImplementation(() => ({}))
|
||||||
'Tauri'
|
}))
|
||||||
)
|
|
||||||
})
|
vi.mock('../path/web', () => ({
|
||||||
})
|
WebPathService: vi.fn().mockImplementation(() => ({}))
|
||||||
|
}))
|
||||||
describe('Service Access', () => {
|
|
||||||
it('should provide access to all required services', () => {
|
vi.mock('../core/web', () => ({
|
||||||
const services = [
|
WebCoreService: vi.fn().mockImplementation(() => ({}))
|
||||||
'theme', 'window', 'events', 'hardware', 'app', 'analytic',
|
}))
|
||||||
'messages', 'mcp', 'threads', 'providers', 'models', 'assistants',
|
|
||||||
'dialog', 'opener', 'updater', 'path', 'core', 'deeplink'
|
vi.mock('../dialog/web', () => ({
|
||||||
]
|
WebDialogService: vi.fn().mockImplementation(() => ({}))
|
||||||
|
}))
|
||||||
services.forEach(serviceName => {
|
|
||||||
expect(typeof serviceHub[serviceName as keyof ServiceHub]).toBe('function')
|
vi.mock('../events/web', () => ({
|
||||||
expect(serviceHub[serviceName as keyof ServiceHub]()).toBeDefined()
|
WebEventsService: vi.fn().mockImplementation(() => ({
|
||||||
})
|
emit: vi.fn(),
|
||||||
})
|
listen: vi.fn()
|
||||||
|
}))
|
||||||
it('should return same service instance on multiple calls', () => {
|
}))
|
||||||
const themeService1 = serviceHub.theme()
|
|
||||||
const themeService2 = serviceHub.theme()
|
vi.mock('../window/web', () => ({
|
||||||
|
WebWindowService: vi.fn().mockImplementation(() => ({}))
|
||||||
expect(themeService1).toBe(themeService2)
|
}))
|
||||||
})
|
|
||||||
})
|
vi.mock('../deeplink/web', () => ({
|
||||||
|
WebDeepLinkService: vi.fn().mockImplementation(() => ({}))
|
||||||
describe('Basic Service Functionality', () => {
|
}))
|
||||||
it('should have working theme service', () => {
|
|
||||||
const theme = serviceHub.theme()
|
vi.mock('../providers/web', () => ({
|
||||||
|
WebProvidersService: vi.fn().mockImplementation(() => ({}))
|
||||||
expect(typeof theme.setTheme).toBe('function')
|
}))
|
||||||
expect(typeof theme.getCurrentWindow).toBe('function')
|
|
||||||
})
|
// Mock dynamic imports for Tauri services
|
||||||
|
vi.mock('../theme/tauri', () => ({
|
||||||
it('should have working events service', () => {
|
TauriThemeService: vi.fn().mockImplementation(() => ({
|
||||||
const events = serviceHub.events()
|
setTheme: vi.fn(),
|
||||||
|
getCurrentWindow: vi.fn()
|
||||||
expect(typeof events.emit).toBe('function')
|
}))
|
||||||
expect(typeof events.listen).toBe('function')
|
}))
|
||||||
})
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@ -40,6 +40,25 @@ describe('Web-Specific Service Tests', () => {
|
|||||||
|
|
||||||
describe('WebProvidersService', () => {
|
describe('WebProvidersService', () => {
|
||||||
it('should use browser fetch for API calls', async () => {
|
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 { WebProvidersService } = await import('../providers/web')
|
||||||
const mockResponse = {
|
const mockResponse = {
|
||||||
ok: true,
|
ok: true,
|
||||||
@ -66,7 +85,7 @@ describe('Web-Specific Service Tests', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
expect(models).toEqual(['gpt-4'])
|
expect(models).toEqual(['gpt-4'])
|
||||||
})
|
}, 10000) // Increase timeout to 10 seconds
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('WebAppService', () => {
|
describe('WebAppService', () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user