generated from nhcarrigan/template
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c67380859 | |||
| f5bc9a5016 | |||
| cf20540a49 | |||
| 70af6a6b72 | |||
| 3194a3cca5 | |||
| 7fecb20ba9 | |||
| 9b3c242333 | |||
| 5d4fa75278 | |||
| edd8fa5b55 | |||
| 7781b2caab | |||
| e397435dbe | |||
| 89e99b1524 | |||
| e40ae989f7 | |||
| 32a74235c0 |
@@ -47,65 +47,6 @@ All new features, fixes, and significant changes should include tests whenever p
|
||||
- Use descriptive test names that explain what behaviour is being tested
|
||||
- Include edge cases and error conditions in test coverage
|
||||
- Mock Tauri APIs using the patterns in `vitest.setup.ts`
|
||||
- **Coverage Goal**: Maintain as close to 100% test coverage as possible across the entire codebase
|
||||
|
||||
### Mocking Strategies
|
||||
|
||||
#### Console Mocking
|
||||
|
||||
When testing code that intentionally logs errors (like error handling paths), mock console methods to prevent stderr output that makes tests appear flaky:
|
||||
|
||||
```typescript
|
||||
it("handles errors gracefully", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Test error handling code
|
||||
await expect(functionThatLogs()).rejects.toThrow();
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Expected error:", expect.any(Error));
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
```
|
||||
|
||||
#### E2E Integration Testing for Cross-Platform Code
|
||||
|
||||
For code that calls platform-specific system APIs (like Windows PowerShell or Linux notify-send), use helper functions that build the command structure without execution. This allows CI to verify cross-platform compatibility on Linux-only containers:
|
||||
|
||||
```rust
|
||||
/// Build notify-send command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
(
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
title.to_string(),
|
||||
body.to_string(),
|
||||
"--urgency=normal".to_string(),
|
||||
"--app-name=Hikari Desktop".to_string(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_command_structure() {
|
||||
let (command, args) = build_notify_send_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "Test Title");
|
||||
assert_eq!(args[1], "Test Body");
|
||||
}
|
||||
```
|
||||
|
||||
This approach:
|
||||
|
||||
- Verifies command structure, argument order, and escaping logic
|
||||
- Tests cross-platform code paths without requiring the target platform
|
||||
- Allows CI to catch regressions in Windows-specific code whilst running on Linux
|
||||
- Keeps tests fast and deterministic (no actual system calls)
|
||||
|
||||
### Example Test Structure
|
||||
|
||||
@@ -130,48 +71,6 @@ describe("FeatureName", () => {
|
||||
});
|
||||
```
|
||||
|
||||
### Adding Tests for New Features
|
||||
|
||||
When developing new features, always add corresponding tests:
|
||||
|
||||
1. **Before implementing**: Consider what needs testing (happy path, edge cases, errors)
|
||||
2. **During implementation**: Write tests alongside the code
|
||||
3. **After implementation**: Run `pnpm test:coverage` to verify coverage remains high
|
||||
4. **Before committing**: Ensure `check-all.sh` passes (includes all tests)
|
||||
|
||||
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
Before committing any changes, **always run the full test suite**:
|
||||
|
||||
```bash
|
||||
./check-all.sh
|
||||
```
|
||||
|
||||
This script runs all checks in the correct order:
|
||||
|
||||
1. Frontend linting (ESLint)
|
||||
2. Frontend formatting (Prettier)
|
||||
3. Frontend type checking (svelte-check)
|
||||
4. Frontend tests with coverage (Vitest)
|
||||
5. Backend linting (Clippy with strict rules)
|
||||
6. Backend tests with coverage (cargo test + llvm-cov)
|
||||
|
||||
**Important**: The script requires Node.js and Rust toolchains to be available:
|
||||
|
||||
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
|
||||
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
|
||||
|
||||
If `check-all.sh` reports any failures:
|
||||
|
||||
1. Read the error messages carefully - they usually explain what needs fixing
|
||||
2. Fix the issues (linting errors, test failures, etc.)
|
||||
3. Run `check-all.sh` again to verify the fixes
|
||||
4. Only commit once all checks pass ✨
|
||||
|
||||
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
|
||||
|
||||
## Project Context
|
||||
|
||||
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.5.1",
|
||||
"version": "1.4.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -60,7 +60,7 @@
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2",
|
||||
"@tauri-apps/plugin-shell": "2.3.5",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"codemirror": "^6.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
||||
Generated
+6
-11
@@ -108,8 +108,8 @@ importers:
|
||||
specifier: ^2
|
||||
version: 2.3.2
|
||||
'@tauri-apps/plugin-shell':
|
||||
specifier: 2.3.5
|
||||
version: 2.3.5
|
||||
specifier: ^2.3.4
|
||||
version: 2.3.4
|
||||
'@tauri-apps/plugin-store':
|
||||
specifier: ^2
|
||||
version: 2.4.2
|
||||
@@ -909,9 +909,6 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tauri-apps/api@2.10.1':
|
||||
resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==}
|
||||
|
||||
'@tauri-apps/api@2.9.1':
|
||||
resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
|
||||
|
||||
@@ -1004,8 +1001,8 @@ packages:
|
||||
'@tauri-apps/plugin-os@2.3.2':
|
||||
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
|
||||
'@tauri-apps/plugin-shell@2.3.4':
|
||||
resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
|
||||
|
||||
'@tauri-apps/plugin-store@2.4.2':
|
||||
resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==}
|
||||
@@ -2872,8 +2869,6 @@ snapshots:
|
||||
tailwindcss: 4.1.18
|
||||
vite: 6.4.1(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
|
||||
'@tauri-apps/api@2.10.1': {}
|
||||
|
||||
'@tauri-apps/api@2.9.1': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.9.6':
|
||||
@@ -2947,9 +2942,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@tauri-apps/plugin-shell@2.3.5':
|
||||
'@tauri-apps/plugin-shell@2.3.4':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.10.1
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@tauri-apps/plugin-store@2.4.2':
|
||||
dependencies:
|
||||
|
||||
Generated
+1
-1
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.5.1"
|
||||
version = "1.4.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.5.1"
|
||||
version = "1.4.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -173,127 +173,3 @@ pub type SharedBridgeManager = Arc<Mutex<BridgeManager>>;
|
||||
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
|
||||
Arc::new(Mutex::new(BridgeManager::new()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bridge_manager_new() {
|
||||
let manager = BridgeManager::new();
|
||||
assert!(manager.app_handle.is_none());
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bridge_manager_default() {
|
||||
let manager = BridgeManager::default();
|
||||
assert!(manager.app_handle.is_none());
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_claude_running_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
assert!(!manager.is_claude_running("nonexistent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_working_directory_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
let result = manager.get_working_directory("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_usage_stats_no_bridge() {
|
||||
let manager = BridgeManager::new();
|
||||
let result = manager.get_usage_stats("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_claude_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.stop_claude("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interrupt_claude_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.interrupt_claude("nonexistent");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_prompt_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.send_prompt("nonexistent", "Hello".to_string());
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_tool_result_no_bridge() {
|
||||
let mut manager = BridgeManager::new();
|
||||
let result = manager.send_tool_result(
|
||||
"nonexistent",
|
||||
"tool_id",
|
||||
serde_json::json!({"result": "success"}),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"No Claude instance found for this conversation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_shared_bridge_manager() {
|
||||
let shared = create_shared_bridge_manager();
|
||||
let manager = shared.lock();
|
||||
assert!(manager.bridges.is_empty());
|
||||
assert!(manager.app_handle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_stopped_bridges_empty() {
|
||||
let mut manager = BridgeManager::new();
|
||||
manager.cleanup_stopped_bridges();
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_active_conversations_empty() {
|
||||
let manager = BridgeManager::new();
|
||||
let active = manager.get_active_conversations();
|
||||
assert!(active.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_all_without_app_handle() {
|
||||
let mut manager = BridgeManager::new();
|
||||
manager.stop_all(); // Should not panic
|
||||
assert!(manager.bridges.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+153
-663
@@ -49,59 +49,6 @@ fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Command instance for executing Claude CLI commands
|
||||
/// On Windows, this will use WSL to execute the command
|
||||
/// On other platforms, it executes directly
|
||||
fn create_claude_command() -> std::process::Command {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Use `which` inside WSL to find the claude binary dynamically
|
||||
// Non-login shells launched by `wsl` don't inherit the full user PATH,
|
||||
// so we need to use a login shell to get the correct PATH
|
||||
let which_output = std::process::Command::new("wsl")
|
||||
.args(["-e", "bash", "-l", "-c", "which claude"])
|
||||
.output();
|
||||
|
||||
match which_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let mut cmd = std::process::Command::new("wsl");
|
||||
cmd.arg(claude_path);
|
||||
cmd
|
||||
}
|
||||
_ => {
|
||||
// Fallback to just "claude" if which fails
|
||||
// This maintains backwards compatibility
|
||||
let mut cmd = std::process::Command::new("wsl");
|
||||
cmd.arg("claude");
|
||||
cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Use `which` to find the claude binary dynamically
|
||||
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
|
||||
// and avoids hardcoding paths
|
||||
let which_output = std::process::Command::new("which")
|
||||
.arg("claude")
|
||||
.output();
|
||||
|
||||
match which_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
std::process::Command::new(claude_path)
|
||||
}
|
||||
_ => {
|
||||
// Fallback to just "claude" if which fails
|
||||
// This maintains backwards compatibility
|
||||
std::process::Command::new("claude")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_claude(
|
||||
bridge_manager: State<'_, SharedBridgeManager>,
|
||||
@@ -1219,55 +1166,6 @@ pub struct MemoryFilesResponse {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
|
||||
// On Windows, we need to look in the WSL home directory
|
||||
// On Linux/Mac, use the native home directory
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
list_memory_files_via_wsl().await
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
list_memory_files_native().await
|
||||
}
|
||||
}
|
||||
|
||||
/// List memory files via WSL (for Windows)
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Use WSL to find all memory files in the WSL home directory
|
||||
// This script finds all "memory" directories and lists their files
|
||||
let script = r#"
|
||||
find ~/.claude/projects -type d -name memory 2>/dev/null | while read dir; do
|
||||
find "$dir" -maxdepth 1 -type f 2>/dev/null
|
||||
done | sort
|
||||
"#;
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.args(["-e", "bash", "-l", "-c", script])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to list memory files: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let files: Vec<String> = stdout
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.map(|line| line.trim().to_string())
|
||||
.collect();
|
||||
|
||||
Ok(MemoryFilesResponse { files })
|
||||
}
|
||||
|
||||
/// List memory files using native filesystem (for Linux/Mac)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
||||
use std::fs;
|
||||
|
||||
// Get the .claude directory in the user's home
|
||||
@@ -1335,7 +1233,7 @@ async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
|
||||
pub async fn get_claude_version() -> Result<String, String> {
|
||||
tracing::debug!("Getting Claude CLI version");
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
@@ -1370,62 +1268,11 @@ pub struct PluginInfo {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Parse plugin list output from Claude CLI
|
||||
fn parse_plugin_list(stdout: &str) -> Vec<PluginInfo> {
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
// Parse text output format:
|
||||
// ❯ macrodata@macrodata
|
||||
// Version: 0.1.3
|
||||
// Scope: user
|
||||
// Status: ✔ enabled
|
||||
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
|
||||
// Look for plugin name line (starts with ❯)
|
||||
if line.starts_with("❯") {
|
||||
let name = line.trim_start_matches("❯").trim().to_string();
|
||||
let mut version = String::new();
|
||||
let mut enabled = false;
|
||||
|
||||
// Parse following lines for metadata
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let meta_line = lines[i].trim();
|
||||
if meta_line.is_empty() || meta_line.starts_with("❯") {
|
||||
break;
|
||||
}
|
||||
|
||||
if meta_line.starts_with("Version:") {
|
||||
version = meta_line.trim_start_matches("Version:").trim().to_string();
|
||||
} else if meta_line.starts_with("Status:") {
|
||||
enabled = meta_line.contains("enabled");
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
plugins.push(PluginInfo {
|
||||
name,
|
||||
version,
|
||||
description: None,
|
||||
enabled,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
plugins
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
||||
tracing::debug!("Listing Claude Code plugins");
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("list")
|
||||
.output();
|
||||
@@ -1434,7 +1281,52 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let plugins = parse_plugin_list(&stdout);
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
// Parse text output format:
|
||||
// ❯ macrodata@macrodata
|
||||
// Version: 0.1.3
|
||||
// Scope: user
|
||||
// Status: ✔ enabled
|
||||
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let line = lines[i].trim();
|
||||
|
||||
// Look for plugin name line (starts with ❯)
|
||||
if line.starts_with("❯") {
|
||||
let name = line.trim_start_matches("❯").trim().to_string();
|
||||
let mut version = String::new();
|
||||
let mut enabled = false;
|
||||
|
||||
// Parse following lines for metadata
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let meta_line = lines[i].trim();
|
||||
if meta_line.is_empty() || meta_line.starts_with("❯") {
|
||||
break;
|
||||
}
|
||||
|
||||
if meta_line.starts_with("Version:") {
|
||||
version = meta_line.trim_start_matches("Version:").trim().to_string();
|
||||
} else if meta_line.starts_with("Status:") {
|
||||
enabled = meta_line.contains("enabled");
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
plugins.push(PluginInfo {
|
||||
name,
|
||||
version,
|
||||
description: None,
|
||||
enabled,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
tracing::info!("Listed {} plugins", plugins.len());
|
||||
Ok(plugins)
|
||||
} else {
|
||||
@@ -1454,7 +1346,7 @@ pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
||||
pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Installing plugin: {}", plugin_name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("install")
|
||||
.arg(&plugin_name)
|
||||
@@ -1483,7 +1375,7 @@ pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
|
||||
pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Uninstalling plugin: {}", plugin_name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("uninstall")
|
||||
.arg(&plugin_name)
|
||||
@@ -1512,7 +1404,7 @@ pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
|
||||
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Enabling plugin: {}", plugin_name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("enable")
|
||||
.arg(&plugin_name)
|
||||
@@ -1541,7 +1433,7 @@ pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Disabling plugin: {}", plugin_name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("disable")
|
||||
.arg(&plugin_name)
|
||||
@@ -1570,7 +1462,7 @@ pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Updating plugin: {}", plugin_name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("update")
|
||||
.arg(&plugin_name)
|
||||
@@ -1603,46 +1495,11 @@ pub struct MarketplaceInfo {
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Parse marketplace list output from Claude CLI
|
||||
fn parse_marketplace_list(stdout: &str) -> Vec<MarketplaceInfo> {
|
||||
let mut marketplaces = Vec::new();
|
||||
|
||||
// Parse format:
|
||||
// Configured marketplaces:
|
||||
//
|
||||
// ❯ claude-plugins-official
|
||||
// Source: GitHub (anthropics/claude-plugins-official)
|
||||
//
|
||||
// ❯ macrodata
|
||||
// Source: GitHub (ascorbic/macrodata)
|
||||
|
||||
let mut current_name: Option<String> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Look for marketplace names starting with ❯
|
||||
if trimmed.starts_with("❯") {
|
||||
current_name = Some(trimmed.trim_start_matches("❯").trim().to_string());
|
||||
}
|
||||
// Look for Source line
|
||||
else if trimmed.starts_with("Source:") && current_name.is_some() {
|
||||
let source = trimmed.trim_start_matches("Source:").trim().to_string();
|
||||
marketplaces.push(MarketplaceInfo {
|
||||
name: current_name.take().unwrap(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
marketplaces
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
||||
tracing::debug!("Listing plugin marketplaces");
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("marketplace")
|
||||
.arg("list")
|
||||
@@ -1657,7 +1514,36 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let marketplaces = parse_marketplace_list(&stdout);
|
||||
let mut marketplaces = Vec::new();
|
||||
|
||||
// Parse format:
|
||||
// Configured marketplaces:
|
||||
//
|
||||
// ❯ claude-plugins-official
|
||||
// Source: GitHub (anthropics/claude-plugins-official)
|
||||
//
|
||||
// ❯ macrodata
|
||||
// Source: GitHub (ascorbic/macrodata)
|
||||
|
||||
let mut current_name: Option<String> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Look for marketplace names starting with ❯
|
||||
if trimmed.starts_with("❯ ") {
|
||||
current_name = Some(trimmed[2..].trim().to_string());
|
||||
}
|
||||
// Look for Source line
|
||||
else if trimmed.starts_with("Source: ") && current_name.is_some() {
|
||||
let source = trimmed[8..].trim().to_string();
|
||||
marketplaces.push(MarketplaceInfo {
|
||||
name: current_name.take().unwrap(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Found {} marketplaces", marketplaces.len());
|
||||
Ok(marketplaces)
|
||||
}
|
||||
@@ -1675,7 +1561,7 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
|
||||
pub async fn add_marketplace(source: String) -> Result<String, String> {
|
||||
tracing::debug!("Adding marketplace: {}", source);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("marketplace")
|
||||
.arg("add")
|
||||
@@ -1708,7 +1594,7 @@ pub async fn add_marketplace(source: String) -> Result<String, String> {
|
||||
pub async fn remove_marketplace(name: String) -> Result<String, String> {
|
||||
tracing::debug!("Removing marketplace: {}", name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("marketplace")
|
||||
.arg("remove")
|
||||
@@ -1749,106 +1635,11 @@ pub struct McpServerInfo {
|
||||
pub status: Option<String>, // "Connected" or "Failed to connect"
|
||||
}
|
||||
|
||||
/// Parse MCP server list output from Claude CLI
|
||||
fn parse_mcp_server_list(stdout: &str) -> Vec<McpServerInfo> {
|
||||
let mut servers = Vec::new();
|
||||
|
||||
// Parse text output format:
|
||||
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
|
||||
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
|
||||
// plugin:macrodata:macrodata: ... - ✓ Connected
|
||||
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("Checking") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the last occurrence of " - ✓" or " - ✗" to split status from the rest
|
||||
let (content, status) = if let Some(pos) = line.rfind(" - ✓").or_else(|| line.rfind(" - ✗")) {
|
||||
let status_str = line[pos + 3..].trim().trim_start_matches("✓").trim_start_matches("✗").trim();
|
||||
(line[..pos].trim(), Some(status_str.to_string()))
|
||||
} else {
|
||||
(line, None)
|
||||
};
|
||||
|
||||
// Now find the name by looking for the first colon followed by either http or a command
|
||||
// The format is: "name: command/url"
|
||||
// But name can contain colons (e.g., "plugin:macrodata:macrodata")
|
||||
// Strategy: Find the colon that separates name from content
|
||||
// - If content after colon starts with "http", it's a URL (name is before first colon)
|
||||
// - If content is a command, name might have colons, so find the last colon before a non-URL space-separated part
|
||||
|
||||
let (name, rest) = if let Some(first_colon) = content.find(':') {
|
||||
let after_first_colon = content[first_colon + 1..].trim_start();
|
||||
|
||||
// Check if it's a URL (starts with http)
|
||||
if after_first_colon.starts_with("http") {
|
||||
// Name is everything before the first colon
|
||||
(content[..first_colon].to_string(), after_first_colon.to_string())
|
||||
} else {
|
||||
// It's a command - name might contain colons (like plugin:foo:bar)
|
||||
// Strategy: Commands start with a letter/word, not with a colon
|
||||
// Find the rightmost colon that has whitespace after it (indicating start of command)
|
||||
let mut split_pos = first_colon;
|
||||
for (idx, _) in content.match_indices(':') {
|
||||
let after = content[idx + 1..].trim_start();
|
||||
// If what comes after this colon is NOT another colon-prefixed part,
|
||||
// and doesn't start with "//" (part of URL), this is our split point
|
||||
if !after.is_empty() && !after.starts_with(':') && !after.starts_with("//") {
|
||||
// Check if this looks like a command (starts with letter/number)
|
||||
if after.chars().next().map(|c| c.is_alphanumeric()).unwrap_or(false) {
|
||||
split_pos = idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(content[..split_pos].to_string(), content[split_pos + 1..].trim_start().to_string())
|
||||
}
|
||||
} else {
|
||||
continue; // Skip lines without colons
|
||||
};
|
||||
|
||||
let name = name.trim().to_string();
|
||||
let rest = rest.trim();
|
||||
|
||||
// Determine if it's a URL or command
|
||||
let (url, command, transport) = if rest.starts_with("http") {
|
||||
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE)"
|
||||
// Extract URL and transport type
|
||||
let (url, transport) = if let Some((url_part, transport_part)) = rest.rsplit_once('(') {
|
||||
let url = url_part.trim().to_string();
|
||||
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
|
||||
(Some(url), transport)
|
||||
} else {
|
||||
(Some(rest.to_string()), "http".to_string())
|
||||
};
|
||||
|
||||
(url, None, transport)
|
||||
} else {
|
||||
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com"
|
||||
// Command is everything in rest
|
||||
(None, Some(rest.to_string()), "stdio".to_string())
|
||||
};
|
||||
|
||||
servers.push(McpServerInfo {
|
||||
name,
|
||||
command,
|
||||
url,
|
||||
transport,
|
||||
env: None,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
servers
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
||||
tracing::debug!("Listing MCP servers");
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("list")
|
||||
.output();
|
||||
@@ -1857,7 +1648,69 @@ pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let servers = parse_mcp_server_list(&stdout);
|
||||
let mut servers = Vec::new();
|
||||
|
||||
// Parse text output format:
|
||||
// asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
|
||||
// gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected
|
||||
|
||||
for line in stdout.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("Checking") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split by colon to get name and rest
|
||||
if let Some((name, rest)) = line.split_once(':') {
|
||||
let name = name.trim().to_string();
|
||||
let rest = rest.trim();
|
||||
|
||||
// Determine if it's a URL or command
|
||||
let (url, command, transport, status) = if rest.starts_with("http") {
|
||||
// HTTP/SSE server: "https://mcp.asana.com/sse (SSE) - ✓ Connected"
|
||||
let parts: Vec<&str> = rest.split('-').collect();
|
||||
let url_and_transport = parts[0].trim();
|
||||
let status = if parts.len() > 1 {
|
||||
Some(parts[1].trim().trim_start_matches("✓").trim_start_matches("✗").trim().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Extract URL and transport type
|
||||
let (url, transport) = if let Some((url_part, transport_part)) = url_and_transport.rsplit_once('(') {
|
||||
let url = url_part.trim().to_string();
|
||||
let transport = transport_part.trim_end_matches(')').trim().to_lowercase();
|
||||
(Some(url), transport)
|
||||
} else {
|
||||
(Some(url_and_transport.to_string()), "http".to_string())
|
||||
};
|
||||
|
||||
(url, None, transport, status)
|
||||
} else {
|
||||
// stdio server: "gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected"
|
||||
let parts: Vec<&str> = rest.split('-').collect();
|
||||
let command = parts[0].trim().to_string();
|
||||
let status = if parts.len() > 1 {
|
||||
let status_part = parts[parts.len() - 1];
|
||||
Some(status_part.trim().trim_start_matches("✓").trim_start_matches("✗").trim().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(None, Some(command), "stdio".to_string(), status)
|
||||
};
|
||||
|
||||
servers.push(McpServerInfo {
|
||||
name,
|
||||
command,
|
||||
url,
|
||||
transport,
|
||||
env: None,
|
||||
status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("Listed {} MCP servers", servers.len());
|
||||
Ok(servers)
|
||||
} else {
|
||||
@@ -1890,7 +1743,7 @@ pub async fn get_mcp_server(name: String) -> Result<McpServerInfo, String> {
|
||||
pub async fn remove_mcp_server(name: String) -> Result<String, String> {
|
||||
tracing::debug!("Removing MCP server: {}", name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("remove")
|
||||
.arg(&name)
|
||||
@@ -1925,7 +1778,7 @@ pub async fn add_mcp_server(
|
||||
) -> Result<String, String> {
|
||||
tracing::debug!("Adding MCP server: {} with transport {}", name, transport);
|
||||
|
||||
let mut cmd = create_claude_command();
|
||||
let mut cmd = std::process::Command::new("claude");
|
||||
cmd.arg("mcp").arg("add");
|
||||
|
||||
// Add transport flag
|
||||
@@ -1973,7 +1826,7 @@ pub async fn add_mcp_server(
|
||||
pub async fn get_mcp_server_details(name: String) -> Result<String, String> {
|
||||
tracing::debug!("Getting detailed info for MCP server: {}", name);
|
||||
|
||||
let output = create_claude_command()
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("get")
|
||||
.arg(&name)
|
||||
@@ -2010,49 +1863,6 @@ mod tests {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(f)
|
||||
}
|
||||
|
||||
// ==================== create_claude_command tests ====================
|
||||
|
||||
#[test]
|
||||
#[cfg(target_os = "windows")]
|
||||
fn test_create_claude_command_windows() {
|
||||
// On Windows, should create a command that uses wsl with full path to claude
|
||||
// The path is resolved dynamically via `which` in a login shell
|
||||
let cmd = create_claude_command();
|
||||
let program = cmd.get_program();
|
||||
|
||||
assert_eq!(program, "wsl");
|
||||
|
||||
// Verify the first argument is a path to claude (full path from `which`)
|
||||
// or fallback to just "claude" if which fails
|
||||
let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
|
||||
assert_eq!(args.len(), 1);
|
||||
|
||||
let arg_str = args[0].to_string_lossy();
|
||||
assert!(
|
||||
arg_str.contains("claude"),
|
||||
"Expected argument to contain 'claude', got: {}",
|
||||
arg_str
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn test_create_claude_command_linux() {
|
||||
// On Linux/Mac, should create a command that uses the full path to claude
|
||||
// (resolved via `which` command)
|
||||
let cmd = create_claude_command();
|
||||
let program = cmd.get_program();
|
||||
|
||||
// The program should be the full path to claude (from `which`)
|
||||
// or fallback to "claude" if which fails
|
||||
let program_str = program.to_string_lossy();
|
||||
assert!(
|
||||
program_str.ends_with("claude"),
|
||||
"Expected program to end with 'claude', got: {}",
|
||||
program_str
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== validate_directory tests ====================
|
||||
|
||||
#[test]
|
||||
@@ -2302,324 +2112,4 @@ mod tests {
|
||||
assert!(json.contains("/tmp/test.txt"));
|
||||
assert!(json.contains("test.txt"));
|
||||
}
|
||||
|
||||
// ==================== CLI Parser Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_single_enabled() {
|
||||
let output = r#"❯ macrodata@macrodata
|
||||
Version: 0.1.3
|
||||
Scope: user
|
||||
Status: ✔ enabled"#;
|
||||
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].name, "macrodata@macrodata");
|
||||
assert_eq!(plugins[0].version, "0.1.3");
|
||||
assert!(plugins[0].enabled);
|
||||
assert_eq!(plugins[0].description, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_single_disabled() {
|
||||
let output = r#"❯ test-plugin@official
|
||||
Version: 2.0.0
|
||||
Status: ✘ disabled"#;
|
||||
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].name, "test-plugin@official");
|
||||
assert_eq!(plugins[0].version, "2.0.0");
|
||||
assert!(!plugins[0].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_multiple() {
|
||||
let output = r#"❯ macrodata@macrodata
|
||||
Version: 0.1.3
|
||||
Status: ✔ enabled
|
||||
|
||||
❯ another-plugin@official
|
||||
Version: 1.5.0
|
||||
Status: ✘ disabled
|
||||
|
||||
❯ third-plugin@test
|
||||
Version: 3.0.0-beta
|
||||
Status: ✔ enabled"#;
|
||||
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 3);
|
||||
|
||||
assert_eq!(plugins[0].name, "macrodata@macrodata");
|
||||
assert_eq!(plugins[0].version, "0.1.3");
|
||||
assert!(plugins[0].enabled);
|
||||
|
||||
assert_eq!(plugins[1].name, "another-plugin@official");
|
||||
assert_eq!(plugins[1].version, "1.5.0");
|
||||
assert!(!plugins[1].enabled);
|
||||
|
||||
assert_eq!(plugins[2].name, "third-plugin@test");
|
||||
assert_eq!(plugins[2].version, "3.0.0-beta");
|
||||
assert!(plugins[2].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_empty() {
|
||||
let output = "";
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_marketplace_list_single() {
|
||||
let output = r#"Configured marketplaces:
|
||||
|
||||
❯ claude-plugins-official
|
||||
Source: GitHub (anthropics/claude-plugins-official)"#;
|
||||
|
||||
let marketplaces = parse_marketplace_list(output);
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert_eq!(marketplaces[0].name, "claude-plugins-official");
|
||||
assert_eq!(marketplaces[0].source, "GitHub (anthropics/claude-plugins-official)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_marketplace_list_multiple() {
|
||||
let output = r#"Configured marketplaces:
|
||||
|
||||
❯ claude-plugins-official
|
||||
Source: GitHub (anthropics/claude-plugins-official)
|
||||
|
||||
❯ macrodata
|
||||
Source: GitHub (ascorbic/macrodata)
|
||||
|
||||
❯ custom-marketplace
|
||||
Source: GitHub (user/custom-marketplace)"#;
|
||||
|
||||
let marketplaces = parse_marketplace_list(output);
|
||||
assert_eq!(marketplaces.len(), 3);
|
||||
|
||||
assert_eq!(marketplaces[0].name, "claude-plugins-official");
|
||||
assert_eq!(marketplaces[1].name, "macrodata");
|
||||
assert_eq!(marketplaces[2].name, "custom-marketplace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_marketplace_list_empty() {
|
||||
let output = "Configured marketplaces:\n\n";
|
||||
let marketplaces = parse_marketplace_list(output);
|
||||
assert_eq!(marketplaces.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_sse_connected() {
|
||||
let output = "asana: https://mcp.asana.com/sse (SSE) - ✓ Connected";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "asana");
|
||||
assert_eq!(servers[0].url, Some("https://mcp.asana.com/sse".to_string()));
|
||||
assert_eq!(servers[0].command, None);
|
||||
assert_eq!(servers[0].transport, "sse");
|
||||
assert_eq!(servers[0].status, Some("Connected".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_http_connected() {
|
||||
let output = "test-server: https://api.example.com/mcp (HTTP) - ✓ Connected";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "test-server");
|
||||
assert_eq!(servers[0].url, Some("https://api.example.com/mcp".to_string()));
|
||||
assert_eq!(servers[0].transport, "http");
|
||||
assert_eq!(servers[0].status, Some("Connected".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_stdio_connected() {
|
||||
let output = "gitea: gitea-mcp -t stdio --host https://git.nhcarrigan.com - ✓ Connected";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "gitea");
|
||||
assert_eq!(servers[0].url, None);
|
||||
assert_eq!(servers[0].command, Some("gitea-mcp -t stdio --host https://git.nhcarrigan.com".to_string()));
|
||||
assert_eq!(servers[0].transport, "stdio");
|
||||
assert_eq!(servers[0].status, Some("Connected".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_failed_connection() {
|
||||
let output = "broken-server: https://invalid.com (SSE) - ✗ Failed to connect";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "broken-server");
|
||||
assert_eq!(servers[0].status, Some("Failed to connect".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_multiple() {
|
||||
let output = r#"asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
|
||||
gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected
|
||||
notion: https://mcp.notion.so (HTTP) - ✓ Connected"#;
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 3);
|
||||
|
||||
assert_eq!(servers[0].name, "asana");
|
||||
assert_eq!(servers[0].transport, "sse");
|
||||
|
||||
assert_eq!(servers[1].name, "gitea");
|
||||
assert_eq!(servers[1].transport, "stdio");
|
||||
|
||||
assert_eq!(servers[2].name, "notion");
|
||||
assert_eq!(servers[2].transport, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_with_checking_line() {
|
||||
let output = r#"Checking MCP servers...
|
||||
asana: https://mcp.asana.com/sse (SSE) - ✓ Connected"#;
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "asana");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_empty() {
|
||||
let output = "";
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_plugin_provided() {
|
||||
let output = "plugin:macrodata:macrodata: plugin macrodata - ✗ Failed to connect";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "plugin:macrodata:macrodata");
|
||||
assert_eq!(servers[0].command, Some("plugin macrodata".to_string()));
|
||||
assert_eq!(servers[0].transport, "stdio");
|
||||
}
|
||||
|
||||
// ==================== Edge Case Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_with_unicode_names() {
|
||||
let output = r#"❯ 日本語-plugin@marketplace
|
||||
Version: 1.0.0
|
||||
Status: ✔ enabled
|
||||
|
||||
❯ émoji-🎉-plugin@marketplace
|
||||
Version: 2.0.0
|
||||
Status: ✗ disabled"#;
|
||||
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 2);
|
||||
assert_eq!(plugins[0].name, "日本語-plugin@marketplace");
|
||||
assert!(plugins[0].enabled);
|
||||
assert_eq!(plugins[1].name, "émoji-🎉-plugin@marketplace");
|
||||
assert!(!plugins[1].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_missing_version() {
|
||||
let output = r#"❯ broken-plugin@marketplace
|
||||
Status: ✔ enabled"#;
|
||||
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].name, "broken-plugin@marketplace");
|
||||
assert_eq!(plugins[0].version, ""); // Empty version
|
||||
assert!(plugins[0].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_missing_status() {
|
||||
let output = r#"❯ incomplete-plugin@marketplace
|
||||
Version: 1.0.0"#;
|
||||
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].name, "incomplete-plugin@marketplace");
|
||||
assert_eq!(plugins[0].version, "1.0.0");
|
||||
assert!(!plugins[0].enabled); // Defaults to false when status missing
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_marketplace_list_with_unicode() {
|
||||
let output = r#"❯ 日本語-marketplace
|
||||
Source: github/日本語/repo
|
||||
|
||||
❯ emoji-🚀-marketplace
|
||||
Source: github/emoji/🚀-repo"#;
|
||||
|
||||
let marketplaces = parse_marketplace_list(output);
|
||||
assert_eq!(marketplaces.len(), 2);
|
||||
assert_eq!(marketplaces[0].name, "日本語-marketplace");
|
||||
assert_eq!(marketplaces[0].source, "github/日本語/repo");
|
||||
assert_eq!(marketplaces[1].name, "emoji-🚀-marketplace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_with_unicode_names() {
|
||||
let output = "日本語-server: https://example.com/日本語 (SSE) - ✓ Connected";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "日本語-server");
|
||||
assert_eq!(servers[0].url, Some("https://example.com/日本語".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_very_long_command() {
|
||||
let output = "long-cmd: some-binary --flag1 value1 --flag2 value2 --flag3 value3 --flag4 value4 --flag5 value5 --very-long-option with-a-very-long-value - ✓ Connected";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "long-cmd");
|
||||
assert_eq!(
|
||||
servers[0].command,
|
||||
Some("some-binary --flag1 value1 --flag2 value2 --flag3 value3 --flag4 value4 --flag5 value5 --very-long-option with-a-very-long-value".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_no_status() {
|
||||
let output = "pending-server: https://example.com (HTTP)";
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0].name, "pending-server");
|
||||
assert_eq!(servers[0].status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_plugin_list_with_extra_whitespace() {
|
||||
let output = r#"❯ whitespace-plugin@marketplace
|
||||
Version: 1.0.0
|
||||
Status: ✔ enabled "#;
|
||||
|
||||
let plugins = parse_plugin_list(output);
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].name, "whitespace-plugin@marketplace");
|
||||
assert_eq!(plugins[0].version, "1.0.0");
|
||||
assert!(plugins[0].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mcp_server_list_multiple_with_checking() {
|
||||
let output = r#"Checking connections...
|
||||
asana: https://mcp.asana.com/sse (SSE) - ✓ Connected
|
||||
gitea: gitea-mcp -t stdio (STDIO) - ✓ Connected"#;
|
||||
|
||||
let servers = parse_mcp_server_list(output);
|
||||
assert_eq!(servers.len(), 2); // Should ignore "Checking" line
|
||||
assert_eq!(servers[0].name, "asana");
|
||||
assert_eq!(servers[1].name, "gitea");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,82 +76,3 @@ where
|
||||
let _ = self.app.emit("debug:log", log_event);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_creation() {
|
||||
let event = DebugLogEvent {
|
||||
level: "info".to_string(),
|
||||
message: "Test message".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(event.level, "info");
|
||||
assert_eq!(event.message, "Test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_serialization() {
|
||||
let event = DebugLogEvent {
|
||||
level: "error".to_string(),
|
||||
message: "Error occurred".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"level\":\"error\""));
|
||||
assert!(json.contains("\"message\":\"Error occurred\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_deserialization() {
|
||||
let json = r#"{"level":"warn","message":"Warning message"}"#;
|
||||
let event: DebugLogEvent = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(event.level, "warn");
|
||||
assert_eq!(event.message, "Warning message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_with_special_characters() {
|
||||
let event = DebugLogEvent {
|
||||
level: "info".to_string(),
|
||||
message: "Message with \"quotes\" and \n newlines".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.level, event.level);
|
||||
assert_eq!(decoded.message, event.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_with_unicode() {
|
||||
let event = DebugLogEvent {
|
||||
level: "debug".to_string(),
|
||||
message: "Unicode: 日本語 🎉".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: DebugLogEvent = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(decoded.message, "Unicode: 日本語 🎉");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_log_event_all_levels() {
|
||||
let levels = vec!["error", "warn", "info", "debug", "trace"];
|
||||
|
||||
for level in levels {
|
||||
let event = DebugLogEvent {
|
||||
level: level.to_string(),
|
||||
message: format!("{} level message", level),
|
||||
};
|
||||
|
||||
assert_eq!(event.level, level);
|
||||
assert!(event.message.contains(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-318
@@ -1,83 +1,6 @@
|
||||
use std::process::Command;
|
||||
use tauri::command;
|
||||
|
||||
/// Generate PowerShell script for Windows Toast Notification
|
||||
fn generate_powershell_toast_script(title: &str, body: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
||||
|
||||
$APP_ID = 'Hikari Desktop'
|
||||
|
||||
$template = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastText02">
|
||||
<text id="1">{}</text>
|
||||
<text id="2">{}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
<audio src="ms-winsoundevent:Notification.Default" />
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml($template)
|
||||
|
||||
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
|
||||
"#,
|
||||
title.replace("\"", "`\""),
|
||||
body.replace("\"", "`\"")
|
||||
)
|
||||
}
|
||||
|
||||
/// Format simple notification message
|
||||
fn format_simple_notification(title: &str, body: &str) -> String {
|
||||
format!("{}\n\n{}", title, body)
|
||||
}
|
||||
|
||||
/// Build notify-send command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_notify_send_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
(
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
title.to_string(),
|
||||
body.to_string(),
|
||||
"--urgency=normal".to_string(),
|
||||
"--app-name=Hikari Desktop".to_string(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build Windows PowerShell command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_windows_powershell_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
let script = generate_powershell_toast_script(title, body);
|
||||
(
|
||||
"pwsh.exe".to_string(),
|
||||
vec![
|
||||
"-NoProfile".to_string(),
|
||||
"-WindowStyle".to_string(),
|
||||
"Hidden".to_string(),
|
||||
"-Command".to_string(),
|
||||
script,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build simple notification command for testing (doesn't execute)
|
||||
#[cfg(test)]
|
||||
fn build_simple_notification_command(title: &str, body: &str) -> (String, Vec<String>) {
|
||||
let message = format_simple_notification(title, body);
|
||||
(
|
||||
"cmd.exe".to_string(),
|
||||
vec!["/c".to_string(), "msg".to_string(), "*".to_string(), message],
|
||||
)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn send_notify_send(title: String, body: String) -> Result<(), String> {
|
||||
// Use notify-send for Linux/WSL
|
||||
@@ -105,7 +28,34 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String>
|
||||
#[command]
|
||||
pub async fn send_windows_notification(title: String, body: String) -> Result<(), String> {
|
||||
// Create PowerShell script for Windows Toast Notification
|
||||
let ps_script = generate_powershell_toast_script(&title, &body);
|
||||
let ps_script = format!(
|
||||
r#"
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
||||
|
||||
$APP_ID = 'Hikari Desktop'
|
||||
|
||||
$template = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastText02">
|
||||
<text id="1">{}</text>
|
||||
<text id="2">{}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
<audio src="ms-winsoundevent:Notification.Default" />
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml($template)
|
||||
|
||||
$toast = New-Object Windows.UI.Notifications.ToastNotification $xml
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($APP_ID).Show($toast)
|
||||
"#,
|
||||
title.replace("\"", "`\""),
|
||||
body.replace("\"", "`\"")
|
||||
);
|
||||
|
||||
// Try PowerShell Core first (pwsh), then fall back to Windows PowerShell
|
||||
let output = Command::new("pwsh.exe")
|
||||
@@ -137,7 +87,7 @@ pub async fn send_windows_notification(title: String, body: String) -> Result<()
|
||||
// Alternative: Use Windows built-in MSG command for simple notifications
|
||||
#[command]
|
||||
pub async fn send_simple_notification(title: String, body: String) -> Result<(), String> {
|
||||
let message = format_simple_notification(&title, &body);
|
||||
let message = format!("{}\n\n{}", title, body);
|
||||
|
||||
Command::new("cmd.exe")
|
||||
.arg("/c")
|
||||
@@ -149,242 +99,3 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(),
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_basic() {
|
||||
let script = generate_powershell_toast_script("Title", "Body");
|
||||
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("Title"));
|
||||
assert!(script.contains("Body"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_escapes_quotes() {
|
||||
let script = generate_powershell_toast_script("Title with \"quotes\"", "Body with \"quotes\"");
|
||||
|
||||
// Quotes should be escaped as `" in PowerShell
|
||||
assert!(script.contains("Title with `\"quotes`\""));
|
||||
assert!(script.contains("Body with `\"quotes`\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_with_special_chars() {
|
||||
let script = generate_powershell_toast_script("Title: Test", "Body\nwith\nnewlines");
|
||||
|
||||
assert!(script.contains("Title: Test"));
|
||||
assert!(script.contains("Body\nwith\nnewlines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_unicode() {
|
||||
let script = generate_powershell_toast_script("日本語 Title", "Unicode: 🎉");
|
||||
|
||||
assert!(script.contains("日本語 Title"));
|
||||
assert!(script.contains("Unicode: 🎉"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_empty() {
|
||||
let script = generate_powershell_toast_script("", "");
|
||||
|
||||
// Should still contain the structure
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_basic() {
|
||||
let message = format_simple_notification("Title", "Body");
|
||||
|
||||
assert_eq!(message, "Title\n\nBody");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_with_newlines() {
|
||||
let message = format_simple_notification("Multi\nLine\nTitle", "Multi\nLine\nBody");
|
||||
|
||||
assert!(message.contains("Multi\nLine\nTitle"));
|
||||
assert!(message.contains("\n\n"));
|
||||
assert!(message.contains("Multi\nLine\nBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_unicode() {
|
||||
let message = format_simple_notification("日本語", "🎉 Unicode");
|
||||
|
||||
assert_eq!(message, "日本語\n\n🎉 Unicode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_empty() {
|
||||
let message = format_simple_notification("", "");
|
||||
|
||||
assert_eq!(message, "\n\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_simple_notification_long_text() {
|
||||
let long_title = "A".repeat(1000);
|
||||
let long_body = "B".repeat(1000);
|
||||
let message = format_simple_notification(&long_title, &long_body);
|
||||
|
||||
assert!(message.starts_with(&long_title));
|
||||
assert!(message.ends_with(&long_body));
|
||||
assert!(message.contains("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_powershell_toast_script_multiple_quotes() {
|
||||
let script = generate_powershell_toast_script(
|
||||
"\"Quoted\" \"Multiple\" \"Times\"",
|
||||
"\"More\" \"Quotes\" \"Here\""
|
||||
);
|
||||
|
||||
// Each quote should be escaped
|
||||
assert!(script.contains("`\"Quoted`\" `\"Multiple`\" `\"Times`\""));
|
||||
assert!(script.contains("`\"More`\" `\"Quotes`\" `\"Here`\""));
|
||||
}
|
||||
|
||||
// E2E Integration Tests - Command Structure Verification
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_command_structure() {
|
||||
let (command, args) = build_notify_send_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "Test Title");
|
||||
assert_eq!(args[1], "Test Body");
|
||||
assert_eq!(args[2], "--urgency=normal");
|
||||
assert_eq!(args[3], "--app-name=Hikari Desktop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_notify_send_with_special_chars() {
|
||||
let (command, args) =
|
||||
build_notify_send_command("Title with \"quotes\"", "Body\nwith\nnewlines");
|
||||
|
||||
assert_eq!(command, "notify-send");
|
||||
assert_eq!(args[0], "Title with \"quotes\"");
|
||||
assert_eq!(args[1], "Body\nwith\nnewlines");
|
||||
// notify-send handles these directly
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_windows_powershell_command_structure() {
|
||||
let (command, args) = build_windows_powershell_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "pwsh.exe");
|
||||
assert_eq!(args.len(), 5);
|
||||
assert_eq!(args[0], "-NoProfile");
|
||||
assert_eq!(args[1], "-WindowStyle");
|
||||
assert_eq!(args[2], "Hidden");
|
||||
assert_eq!(args[3], "-Command");
|
||||
|
||||
// Verify the script in args[4] contains expected elements
|
||||
let script = &args[4];
|
||||
assert!(script.contains("Test Title"));
|
||||
assert!(script.contains("Test Body"));
|
||||
assert!(script.contains("Hikari Desktop"));
|
||||
assert!(script.contains("ToastNotification"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_windows_powershell_quote_escaping() {
|
||||
let (_, args) =
|
||||
build_windows_powershell_command("Title with \"quotes\"", "Body with \"quotes\"");
|
||||
|
||||
let script = &args[4];
|
||||
// Verify quotes are properly escaped in the PowerShell script
|
||||
assert!(script.contains("Title with `\"quotes`\""));
|
||||
assert!(script.contains("Body with `\"quotes`\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_simple_notification_command_structure() {
|
||||
let (command, args) = build_simple_notification_command("Test Title", "Test Body");
|
||||
|
||||
assert_eq!(command, "cmd.exe");
|
||||
assert_eq!(args.len(), 4);
|
||||
assert_eq!(args[0], "/c");
|
||||
assert_eq!(args[1], "msg");
|
||||
assert_eq!(args[2], "*");
|
||||
assert_eq!(args[3], "Test Title\n\nTest Body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_simple_notification_multiline() {
|
||||
let (_, args) =
|
||||
build_simple_notification_command("Multi\nLine\nTitle", "Multi\nLine\nBody");
|
||||
|
||||
let message = &args[3];
|
||||
assert!(message.contains("Multi\nLine\nTitle"));
|
||||
assert!(message.contains("\n\n"));
|
||||
assert!(message.contains("Multi\nLine\nBody"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_command_consistency_across_platforms() {
|
||||
// Test that different platforms use consistent parameters
|
||||
let title = "Consistency Test";
|
||||
let body = "Testing cross-platform consistency";
|
||||
|
||||
// Linux command
|
||||
let (notify_cmd, notify_args) = build_notify_send_command(title, body);
|
||||
assert!(notify_cmd.contains("notify"));
|
||||
assert!(notify_args.iter().any(|arg| arg.contains("Hikari Desktop")));
|
||||
|
||||
// Windows PowerShell command
|
||||
let (ps_cmd, ps_args) = build_windows_powershell_command(title, body);
|
||||
assert!(ps_cmd.contains("pwsh") || ps_cmd.contains("powershell"));
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains("Hikari Desktop"));
|
||||
|
||||
// Windows simple command
|
||||
let (msg_cmd, msg_args) = build_simple_notification_command(title, body);
|
||||
assert!(msg_cmd.contains("cmd"));
|
||||
assert!(msg_args[3].contains(title));
|
||||
assert!(msg_args[3].contains(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_unicode_support_across_platforms() {
|
||||
let title = "日本語 Title";
|
||||
let body = "Unicode: 🎉";
|
||||
|
||||
// Verify all platforms preserve unicode
|
||||
let (_, notify_args) = build_notify_send_command(title, body);
|
||||
assert_eq!(notify_args[0], title);
|
||||
assert_eq!(notify_args[1], body);
|
||||
|
||||
let (_, ps_args) = build_windows_powershell_command(title, body);
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains(title));
|
||||
assert!(ps_script.contains(body));
|
||||
|
||||
let (_, msg_args) = build_simple_notification_command(title, body);
|
||||
assert!(msg_args[3].contains(title));
|
||||
assert!(msg_args[3].contains(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_e2e_empty_input_handling() {
|
||||
// Test that empty inputs are handled gracefully
|
||||
let (_, notify_args) = build_notify_send_command("", "");
|
||||
assert_eq!(notify_args[0], "");
|
||||
assert_eq!(notify_args[1], "");
|
||||
|
||||
let (_, ps_args) = build_windows_powershell_command("", "");
|
||||
let ps_script = &ps_args[4];
|
||||
assert!(ps_script.contains("Hikari Desktop")); // Still has app name
|
||||
|
||||
let (_, msg_args) = build_simple_notification_command("", "");
|
||||
assert_eq!(msg_args[3], "\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,11 +129,6 @@ impl WslBridge {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
// Check if Claude binary is installed before attempting to start
|
||||
if Command::new("which").arg("claude").output().ok().is_none_or(|output| !output.status.success()) {
|
||||
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
|
||||
}
|
||||
|
||||
// Load saved achievements and stats when starting a new session
|
||||
let app_clone = app.clone();
|
||||
let stats = self.stats.clone();
|
||||
@@ -683,34 +678,6 @@ fn handle_stderr(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a SubagentStop hook message
|
||||
if line.contains("[SubagentStop Hook]") {
|
||||
if let Some(stop_data) = parse_subagent_stop_hook(&line) {
|
||||
tracing::debug!("Parsed SubagentStop hook: tool_use_id={:?}",
|
||||
stop_data.parent_tool_use_id);
|
||||
|
||||
// Emit agent-end event if we have a tool_use_id
|
||||
if let Some(tool_use_id) = stop_data.parent_tool_use_id {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64;
|
||||
|
||||
let _ = app.emit(
|
||||
"claude:agent-end",
|
||||
AgentEndEvent {
|
||||
tool_use_id,
|
||||
ended_at: now,
|
||||
is_error: false,
|
||||
conversation_id: conversation_id.clone(),
|
||||
duration_ms: None,
|
||||
num_turns: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Still emit the stderr line as output
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
@@ -765,30 +732,6 @@ fn parse_subagent_start_hook(line: &str) -> Option<SubagentStartData> {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SubagentStopData {
|
||||
parent_tool_use_id: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_subagent_stop_hook(line: &str) -> Option<SubagentStopData> {
|
||||
// Parse: [SubagentStop Hook] ... parent_tool_use_id=Some("toolu_xxx"), ...
|
||||
|
||||
// Extract parent_tool_use_id if present
|
||||
let parent_tool_use_id = if line.contains("parent_tool_use_id=Some") {
|
||||
line.split("parent_tool_use_id=Some(\"")
|
||||
.nth(1)?
|
||||
.split('"')
|
||||
.next()
|
||||
.map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(SubagentStopData {
|
||||
parent_tool_use_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn process_json_line(
|
||||
line: &str,
|
||||
app: &AppHandle,
|
||||
@@ -1873,22 +1816,6 @@ mod tests {
|
||||
assert!(!bridge.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claude_binary_check_command_structure() {
|
||||
// Test that we're using the correct command to check for Claude binary
|
||||
let output = Command::new("which").arg("claude").output();
|
||||
|
||||
// The command should execute successfully (even if claude is not found)
|
||||
// We're just verifying the command structure is valid
|
||||
assert!(output.is_ok(), "which command should execute without error");
|
||||
|
||||
// Verify the check logic returns a boolean
|
||||
// This is the same logic used in start() to check if claude is installed
|
||||
let _result = output.ok().is_none_or(|o| !o.status.success());
|
||||
// If claude is not installed, _result will be true (show error)
|
||||
// If claude is installed, _result will be false (proceed with connection)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_shared_bridge_manager() {
|
||||
use crate::bridge_manager::create_shared_bridge_manager;
|
||||
@@ -1896,98 +1823,4 @@ mod tests {
|
||||
let manager = shared.lock();
|
||||
assert!(manager.get_active_conversations().is_empty());
|
||||
}
|
||||
|
||||
// SubagentStart hook parsing tests
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_with_parent() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-abc123, parent_tool_use_id=Some("toolu_01XYZ789"), session_id=123"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-abc123");
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_01XYZ789".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_without_parent() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-xyz789, parent_tool_use_id=None, session_id=456"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-xyz789");
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_invalid() {
|
||||
let line = "[SubagentStart Hook] invalid data";
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_start_hook_with_extra_fields() {
|
||||
let line = r#"[SubagentStart Hook] agent_id=agent-test, parent_tool_use_id=Some("toolu_test"), session_id=789, cwd=/home/user"#;
|
||||
let result = parse_subagent_start_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.agent_id, "agent-test");
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_test".to_string()));
|
||||
}
|
||||
|
||||
// SubagentStop hook parsing tests
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_parent() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=Some("toolu_01ABC123"), session_id=123"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_01ABC123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_without_parent() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=true, parent_tool_use_id=None, session_id=456"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_minimal() {
|
||||
let line = r#"[SubagentStop Hook] parent_tool_use_id=Some("toolu_minimal")"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_minimal".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_with_extra_fields() {
|
||||
let line = r#"[SubagentStop Hook] stop_hook_active=false, parent_tool_use_id=Some("toolu_extra"), session_id=789, transcript_path=/path/to/transcript"#;
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, Some("toolu_extra".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_subagent_stop_hook_empty() {
|
||||
let line = "[SubagentStop Hook]";
|
||||
let result = parse_subagent_stop_hook(line);
|
||||
|
||||
// Should still return Some with None parent_tool_use_id
|
||||
assert!(result.is_some());
|
||||
let data = result.unwrap();
|
||||
assert_eq!(data.parent_tool_use_id, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.5.1",
|
||||
"version": "1.4.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -118,8 +118,6 @@
|
||||
|
||||
try {
|
||||
await invoke("interrupt_claude", { conversationId: currentConversationId });
|
||||
// Mark all running agents as errored after killing the process
|
||||
agentStore.markAllErrored(currentConversationId);
|
||||
} catch (error) {
|
||||
console.error("Failed to kill Claude process:", error);
|
||||
}
|
||||
|
||||
@@ -1071,7 +1071,6 @@ User: ${formattedMessage}`;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
@@ -1088,18 +1087,6 @@ User: ${formattedMessage}`;
|
||||
transition: all 0.2s;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide button text on smaller screens, show icons only */
|
||||
@media (max-width: 640px) {
|
||||
.control-button span {
|
||||
display: none;
|
||||
}
|
||||
.control-button {
|
||||
padding: 10px;
|
||||
min-width: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.control-button:hover {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
command: string | null;
|
||||
url: string | null;
|
||||
transport: string; // "stdio", "http", or "sse"
|
||||
env: Record<string, string> | null;
|
||||
env: any | null;
|
||||
status: string | null; // "Connected" or "Failed to connect"
|
||||
}
|
||||
|
||||
@@ -184,9 +184,7 @@
|
||||
|
||||
<!-- Add Server Form -->
|
||||
{#if showAddForm}
|
||||
<div
|
||||
class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg"
|
||||
>
|
||||
<div class="mx-4 mt-4 p-4 bg-[var(--bg-secondary)]/50 border border-[var(--border-color)] rounded-lg">
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-3">Add MCP Server</h3>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
@@ -313,9 +311,7 @@
|
||||
|
||||
<!-- Server Details Panel -->
|
||||
{#if selectedServer}
|
||||
<div
|
||||
class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]"
|
||||
>
|
||||
<div class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
|
||||
|
||||
{#if isLoadingDetails}
|
||||
@@ -380,11 +376,7 @@
|
||||
>Environment</label
|
||||
>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(
|
||||
selectedServer.env,
|
||||
null,
|
||||
2
|
||||
)}</pre>
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(selectedServer.env, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -402,8 +394,8 @@
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => selectedServer && removeServer(selectedServer.name)}
|
||||
disabled={actionInProgress === selectedServer?.name}
|
||||
onclick={() => removeServer(selectedServer.name)}
|
||||
disabled={actionInProgress === selectedServer.name}
|
||||
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import Markdown from "./Markdown.svelte";
|
||||
|
||||
let memoryFiles: string[] = $state([]);
|
||||
@@ -32,8 +33,7 @@
|
||||
isLoading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Use our backend command instead of Tauri plugin to handle WSL paths
|
||||
const content = await invoke<string>("read_file_content", { path: filePath });
|
||||
const content = await readTextFile(filePath);
|
||||
fileContent = content;
|
||||
selectedFile = filePath;
|
||||
} catch (e) {
|
||||
|
||||
@@ -17,21 +17,8 @@
|
||||
let isImporting = $state(false);
|
||||
let showClearAllConfirm = $state(false);
|
||||
|
||||
let sessions = $state<SessionListItem[]>([]);
|
||||
let isLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const unsubSessions = sessionsStore.sessions.subscribe((value) => {
|
||||
sessions = value;
|
||||
});
|
||||
const unsubLoading = sessionsStore.isLoading.subscribe((value) => {
|
||||
isLoading = value;
|
||||
});
|
||||
return () => {
|
||||
unsubSessions();
|
||||
unsubLoading();
|
||||
};
|
||||
});
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
sessionsStore.loadSessions();
|
||||
@@ -316,11 +303,11 @@
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{#if isLoading}
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
||||
</div>
|
||||
{:else if sessions.length === 0}
|
||||
{:else if $sessions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
@@ -342,7 +329,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each sessions as session (session.id)}
|
||||
{#each $sessions as session (session.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
||||
@@ -464,6 +451,7 @@
|
||||
</div>
|
||||
|
||||
{#if showClearAllConfirm}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
|
||||
onclick={() => (showClearAllConfirm = false)}
|
||||
@@ -471,6 +459,7 @@
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && (showClearAllConfirm = false)}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-red-500/30 rounded-lg shadow-xl max-w-md w-full p-6"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -381,16 +381,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if streamerModeActive}
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse shrink-0"
|
||||
class="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"
|
||||
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||
></div>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (showProfile = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover shrink-0"
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Profile"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -696,7 +696,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
|
||||
<div class="fixed top-14 right-4 z-50 max-h-[calc(100vh-4rem)] overflow-y-auto">
|
||||
<div class="fixed top-14 right-4 z-50">
|
||||
<StatsDisplay
|
||||
onRequestSummary={handleCompactConversation}
|
||||
onStartFreshWithContext={handleStartFreshWithContext}
|
||||
@@ -744,32 +744,3 @@
|
||||
{#if showMcpPanel}
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Responsive status bar styling */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Make all icon buttons shrink but not grow */
|
||||
.status-bar button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Hide version text on very small screens */
|
||||
@media (max-width: 640px) {
|
||||
.status-bar button span:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack left and right sections on very small screens */
|
||||
@media (max-width: 768px) {
|
||||
.status-bar {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,7 +47,12 @@
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { todos } from "$lib/stores/todos";
|
||||
import { todos, type TodoItem } from "$lib/stores/todos";
|
||||
import { CheckCircle, Circle, Loader } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { agentStore, getAgentsForConversation, runningAgentCount } from "./agents";
|
||||
import { get } from "svelte/store";
|
||||
import type { AgentInfo } from "$lib/types/agents";
|
||||
|
||||
describe("agents store", () => {
|
||||
const conversationId = "test-conversation-1";
|
||||
const otherConversationId = "test-conversation-2";
|
||||
|
||||
const createMockAgent = (overrides?: Partial<AgentInfo>): AgentInfo => ({
|
||||
toolUseId: "toolu_test123",
|
||||
description: "Test agent",
|
||||
subagentType: "Explore",
|
||||
startedAt: Date.now(),
|
||||
status: "running",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear all conversations by subscribing and getting state
|
||||
let state: Record<string, AgentInfo[]> = {};
|
||||
const unsub = agentStore.subscribe((s) => {
|
||||
state = s;
|
||||
});
|
||||
unsub();
|
||||
|
||||
// Clear each conversation
|
||||
for (const convId of Object.keys(state)) {
|
||||
agentStore.clearConversation(convId);
|
||||
}
|
||||
});
|
||||
|
||||
describe("addAgent", () => {
|
||||
it("adds an agent to a conversation", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0]).toEqual(agent);
|
||||
});
|
||||
|
||||
it("adds multiple agents to the same conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(2);
|
||||
expect(agents[0]).toEqual(agent1);
|
||||
expect(agents[1]).toEqual(agent2);
|
||||
});
|
||||
|
||||
it("keeps agents in different conversations separate", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
const agents1 = get(getAgentsForConversation(conversationId));
|
||||
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||
|
||||
expect(agents1).toHaveLength(1);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents1[0]).toEqual(agent1);
|
||||
expect(agents2[0]).toEqual(agent2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateAgentId", () => {
|
||||
it("updates the agent_id for a specific agent", () => {
|
||||
const agent = createMockAgent({ agentId: undefined });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.updateAgentId(conversationId, agent.toolUseId, "agent-abc123");
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBe("agent-abc123");
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.updateAgentId("nonexistent", "tool1", "agent1");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if tool_use_id doesn't exist", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.updateAgentId(conversationId, "nonexistent-tool", "agent1");
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].agentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("endAgent", () => {
|
||||
it("marks an agent as completed", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("completed");
|
||||
expect(agents[0].endedAt).toBe(endTime);
|
||||
expect(agents[0].durationMs).toBeGreaterThanOrEqual(0); // Duration can be 0 if timestamps are the same
|
||||
});
|
||||
|
||||
it("marks an agent as errored", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, true);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("errored");
|
||||
expect(agents[0].endedAt).toBe(endTime);
|
||||
});
|
||||
|
||||
it("calculates duration correctly", () => {
|
||||
const startTime = Date.now() - 5000; // 5 seconds ago
|
||||
const agent = createMockAgent({ startedAt: startTime, status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
const endTime = Date.now();
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, endTime, false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].durationMs).toBeGreaterThanOrEqual(5000);
|
||||
expect(agents[0].durationMs).toBeLessThanOrEqual(6000); // Allow some buffer
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.endAgent("nonexistent", "tool1", Date.now(), false);
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if agent doesn't exist", () => {
|
||||
const agent = createMockAgent();
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.endAgent(conversationId, "nonexistent-tool", Date.now(), false);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("running"); // Status unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAllErrored", () => {
|
||||
it("marks all running agents as errored", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
|
||||
agentStore.markAllErrored(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("errored");
|
||||
expect(agents[0].endedAt).toBeGreaterThan(0);
|
||||
expect(agents[1].status).toBe("errored");
|
||||
expect(agents[1].endedAt).toBeGreaterThan(0);
|
||||
expect(agents[2].status).toBe("completed"); // Already completed, unchanged
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.markAllErrored("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing if conversation has no running agents", () => {
|
||||
const agent = createMockAgent({ status: "completed" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
agentStore.markAllErrored(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents[0].status).toBe("completed"); // Unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearCompleted", () => {
|
||||
it("removes completed and errored agents", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "completed" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
|
||||
agentStore.clearCompleted(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(1);
|
||||
expect(agents[0].toolUseId).toBe("tool1"); // Only running agent remains
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.clearCompleted("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("clears all agents if all are completed", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "completed" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
agentStore.clearCompleted(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearConversation", () => {
|
||||
it("removes all agents from a conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
const agents = get(getAgentsForConversation(conversationId));
|
||||
expect(agents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("only removes agents from the specified conversation", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
const agents1 = get(getAgentsForConversation(conversationId));
|
||||
const agents2 = get(getAgentsForConversation(otherConversationId));
|
||||
|
||||
expect(agents1).toHaveLength(0);
|
||||
expect(agents2).toHaveLength(1);
|
||||
expect(agents2[0]).toEqual(agent2);
|
||||
});
|
||||
|
||||
it("does nothing if conversation doesn't exist", () => {
|
||||
agentStore.clearConversation("nonexistent");
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runningAgentCount", () => {
|
||||
it("counts running agents across all conversations", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
const agent3 = createMockAgent({ toolUseId: "tool3", status: "completed" });
|
||||
const agent4 = createMockAgent({ toolUseId: "tool4", status: "running" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
agentStore.addAgent(conversationId, agent3);
|
||||
agentStore.addAgent(otherConversationId, agent4);
|
||||
|
||||
const count = get(runningAgentCount);
|
||||
expect(count).toBe(3); // 2 from first conversation + 1 from second
|
||||
});
|
||||
|
||||
it("returns 0 when no agents are running", () => {
|
||||
const agent1 = createMockAgent({ status: "completed" });
|
||||
const agent2 = createMockAgent({ status: "errored" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(otherConversationId, agent2);
|
||||
|
||||
const count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("updates when agents complete", () => {
|
||||
const agent = createMockAgent({ status: "running" });
|
||||
agentStore.addAgent(conversationId, agent);
|
||||
|
||||
let count = get(runningAgentCount);
|
||||
expect(count).toBe(1);
|
||||
|
||||
agentStore.endAgent(conversationId, agent.toolUseId, Date.now(), false);
|
||||
|
||||
count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("updates when conversation is cleared", () => {
|
||||
const agent1 = createMockAgent({ toolUseId: "tool1", status: "running" });
|
||||
const agent2 = createMockAgent({ toolUseId: "tool2", status: "running" });
|
||||
|
||||
agentStore.addAgent(conversationId, agent1);
|
||||
agentStore.addAgent(conversationId, agent2);
|
||||
|
||||
let count = get(runningAgentCount);
|
||||
expect(count).toBe(2);
|
||||
|
||||
agentStore.clearConversation(conversationId);
|
||||
|
||||
count = get(runningAgentCount);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -722,9 +722,6 @@ describe("config store", () => {
|
||||
it("handles save errors gracefully without losing data", async () => {
|
||||
const mockInvokeImpl = vi.mocked(invoke);
|
||||
|
||||
// Mock console.error to suppress expected error output
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Set initial config
|
||||
await configStore.updateConfig({ font_size: 14 });
|
||||
|
||||
@@ -736,12 +733,6 @@ describe("config store", () => {
|
||||
|
||||
// Original config should still be accessible
|
||||
expect(configStore.getConfig().font_size).toBe(14);
|
||||
|
||||
// Verify error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to save config:", expect.any(Error));
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { CharacterState } from "$lib/types/states";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
import { agentStore } from "$lib/stores/agents";
|
||||
|
||||
export interface ConversationSummary {
|
||||
generatedAt: Date;
|
||||
@@ -334,10 +333,6 @@ function createConversationsStore() {
|
||||
// Clean up tracking for this conversation (including temp files)
|
||||
await cleanupConversationTracking(id);
|
||||
|
||||
// Clean up agent tracking for this conversation
|
||||
// This prevents the badge from persisting after tab close
|
||||
agentStore.clearConversation(id);
|
||||
|
||||
conversations.update((c) => {
|
||||
c.delete(id);
|
||||
return c;
|
||||
|
||||
+1
-3
@@ -183,9 +183,6 @@ export async function initializeTauriListeners() {
|
||||
// (permission prompts trigger reconnects and agents may complete before reconnect)
|
||||
if (!skipNextGreeting && targetConversationId) {
|
||||
agentStore.markAllErrored(targetConversationId);
|
||||
// Clear the conversation's agents from the store on real disconnect
|
||||
// This prevents agents from persisting across sessions
|
||||
agentStore.clearConversation(targetConversationId);
|
||||
}
|
||||
|
||||
// Only remove from connected set if we're not about to reconnect
|
||||
@@ -469,6 +466,7 @@ export async function initializeDiscordRpc() {
|
||||
|
||||
console.log("Discord RPC initialized successfully with initial presence");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to initialize Discord RPC:", error);
|
||||
console.warn("Discord RPC will be unavailable. Make sure Discord is running.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user