feat: improve testing (#6395)

* add more test rust test

* fix servicehub test

* fix tauri failing on windows
This commit is contained in:
Dinh Long Nguyen 2025-09-09 12:16:25 +07:00 committed by GitHub
parent dbaf563e88
commit 5cd81bc6e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1105 additions and 244 deletions

View File

@ -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

View File

@ -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]

View File

@ -1,6 +1,7 @@
{
"name": "jan-app",
"private": true,
"type": "module",
"workspaces": {
"packages": [
"core",

View File

@ -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"] }

View File

@ -228,3 +228,186 @@ fn parse_memory_value(mem_str: &str) -> ServerResult<i32> {
.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");
}
}

View File

@ -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<P: AsRef<std::path::Path>>(path: P) -> Option<String> {
let wide: Vec<u16> = 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<PathBuf> {
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<String>) -> ServerResult<PathBuf> {
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<String>) -> ServerResult<Option<PathBuf>> {
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<P: AsRef<std::path::Path>>(path: P) -> Option<String> {
let wide: Vec<u16> = 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<PathBuf> {
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<String>) -> ServerResult<PathBuf> {
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<String>) -> ServerResult<Option<PathBuf>> {
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));
}
}

View File

@ -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"]

View File

@ -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"));
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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')
})
})
})

View File

@ -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', () => {