Files
galaxy/app/src/ai/skills/file_watchers/skill_watcher_tests.rs
T

399 lines
15 KiB
Rust

use std::{
collections::{HashMap, HashSet},
fs,
};
use crate::ai::skills::skill_manager::SkillWatcherEvent;
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use repo_metadata::{
repositories::DetectedRepositories, DirectoryWatcher, RepoMetadataModel, RepositoryUpdate,
TargetFile,
};
use tempfile::TempDir;
use warp_util::standardized_path::StandardizedPath;
use warpui::App;
use super::SkillWatcher;
/// Helper function for creating a single skill file
fn create_skill_file(dir: &TempDir, name: &str, description: &str, content: &str) -> ParsedSkill {
let skill_content = format!(
r#"---
name: {}
description: {}
---
{}
"#,
name, description, content
);
let skills_path = dir.path().join(".agents").join("skills");
let skill_dir_path = skills_path.join(name);
let skill_file_path = skill_dir_path.join("SKILL.md");
fs::create_dir_all(&skill_dir_path).unwrap();
fs::write(&skill_file_path, skill_content.clone()).unwrap();
let line_range_start = skill_content.clone().lines().count() - content.lines().count() + 1;
let line_range_end = skill_content.clone().lines().count() + 1;
ParsedSkill {
path: skill_file_path,
name: name.to_string(),
description: description.to_string(),
content: skill_content.clone(),
line_range: Some(line_range_start..line_range_end),
provider: SkillProvider::Agents,
scope: SkillScope::Project,
}
}
// ============================================================================
// Tests for handle_repository_update
// ============================================================================
#[test]
fn test_handle_repository_update_single_skill_added() {
let (tx, rx) = async_channel::unbounded();
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let skill = create_skill_file(&temp_dir, "test", "Test skill", "Test content");
let update = RepositoryUpdate {
added: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
modified: HashSet::new(),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
let event = rx.recv().await.unwrap();
assert_eq!(
event,
SkillWatcherEvent::SkillsAdded {
skills: vec![skill]
}
);
});
}
#[test]
fn test_handle_repository_update_skill_modified() {
let (tx, rx) = async_channel::unbounded();
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let skill = create_skill_file(&temp_dir, "test", "Test skill", "Test content");
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
let event = rx.recv().await.unwrap();
assert_eq!(
event,
SkillWatcherEvent::SkillsAdded {
skills: vec![skill]
}
);
});
}
#[test]
fn test_handle_repository_update_skill_deleted() {
let (tx, rx) = async_channel::unbounded();
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let skill = create_skill_file(&temp_dir, "test", "Test skill", "Test content");
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::new(),
deleted: HashSet::from([TargetFile::new(skill.path.clone(), false)]),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
let event = rx.recv().await.unwrap();
assert_eq!(
event,
SkillWatcherEvent::SkillsDeleted {
paths: vec![skill.path]
}
);
});
}
#[test]
fn test_handle_repository_update_multiple_skills_deleted() {
let (tx, rx) = async_channel::unbounded();
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let skill_a = create_skill_file(&temp_dir, "skill-a", "Skill A", "Content A");
let skill_b = create_skill_file(&temp_dir, "skill-b", "Skill B", "Content B");
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::new(),
deleted: HashSet::from([
TargetFile::new(skill_a.path.clone(), false),
TargetFile::new(skill_b.path.clone(), false),
]),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
let event = rx.recv().await.unwrap();
let SkillWatcherEvent::SkillsDeleted { mut paths } = event else {
panic!("Expected SkillsDeleted event");
};
paths.sort();
let mut expected = vec![skill_a.path, skill_b.path];
expected.sort();
assert_eq!(paths, expected);
});
}
#[test]
fn test_handle_repository_update_skill_moved() {
let (tx, rx) = async_channel::unbounded();
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let old_skill = create_skill_file(&temp_dir, "old-skill", "Old skill", "Old content");
let new_skill = create_skill_file(&temp_dir, "new-skill", "New skill", "New content");
// moved is HashMap<to_target, from_target>
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::new(),
deleted: HashSet::new(),
moved: HashMap::from([(
TargetFile::new(new_skill.path.clone(), false),
TargetFile::new(old_skill.path.clone(), false),
)]),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
// Collect both events: SkillsAdded for the new location and SkillsDeleted for the old
let event1 = rx.recv().await.unwrap();
let event2 = rx.recv().await.unwrap();
let added_event = SkillWatcherEvent::SkillsAdded {
skills: vec![new_skill],
};
let deleted_event = SkillWatcherEvent::SkillsDeleted {
paths: vec![old_skill.path],
};
assert!(
(event1 == added_event && event2 == deleted_event)
|| (event1 == deleted_event && event2 == added_event),
"Expected one SkillsAdded and one SkillsDeleted event; got: {event1:?} and {event2:?}"
);
});
}
// ============================================================================
// Tests for handle_repository_update - directory addition
// ============================================================================
/// When a non-skill directory is added within a known repo, `handle_repository_update` should
/// queue the repo root in `queued_project_directory_creations` for a later skill scan.
#[test]
fn test_handle_repository_update_non_skill_directory_added_queues_project_directory() {
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
let detected_repos_handle = app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let (tx, _rx) = async_channel::unbounded();
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let canonical_repo = StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap();
// Register the temp dir as a known repo root so get_root_for_path resolves it.
detected_repos_handle.update(&mut app, |repos, _| {
repos.insert_test_repo_root(canonical_repo.clone());
});
// Seed watched_repos so get_watched_repo_path can resolve the temp dir to this root.
// Use the canonicalized path to match what CanonicalizedPath::try_from resolves on macOS
// (where /var is a symlink to /private/var).
skill_watcher_handle.update(&mut app, |watcher, _| {
watcher
.watched_repos
.insert(canonical_repo.to_local_path().unwrap());
});
// The added path must exist on disk for CanonicalizedPath resolution.
let new_dir = canonical_repo.to_local_path().unwrap().join("new-feature");
fs::create_dir_all(&new_dir).unwrap();
let update = RepositoryUpdate {
added: HashSet::from([TargetFile::new(new_dir, false)]),
modified: HashSet::new(),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
// The repo root should be queued for a skill scan.
skill_watcher_handle.read(&app, |watcher, _| {
assert_eq!(watcher.queued_project_directory_creations.len(), 1);
assert_eq!(
watcher.queued_project_directory_creations[0].path,
canonical_repo.to_local_path().unwrap()
);
});
});
}
/// A modified non-skill file in a known repo should NOT queue anything in
/// `queued_project_directory_creations`; only directory additions can introduce new skill files.
#[test]
fn test_handle_repository_update_non_skill_file_modified_in_repo_does_not_queue_project_directory()
{
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
let detected_repos_handle = app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let (tx, _rx) = async_channel::unbounded();
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let canonical_repo = StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap();
detected_repos_handle.update(&mut app, |repos, _| {
repos.insert_test_repo_root(canonical_repo.clone());
});
// Create the file on disk so CanonicalizedPath resolution succeeds.
let readme = temp_dir.path().join("README.md");
fs::write(&readme, "# Project").unwrap();
let update = RepositoryUpdate {
added: HashSet::new(),
modified: HashSet::from([TargetFile::new(readme, false)]),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
// Modifying a plain file must NOT queue a project directory scan.
skill_watcher_handle.read(&app, |watcher, _| {
assert_eq!(watcher.queued_project_directory_creations.len(), 0);
});
});
}
/// When a regular (non-skill) file is added within a known repo, `handle_repository_update`
/// should NOT queue anything in `queued_project_directory_creations` because only directory
/// additions may introduce new skill files.
#[test]
fn test_handle_repository_update_non_skill_file_added_does_not_queue_project_directory() {
App::test((), |mut app| async move {
app.add_singleton_model(DirectoryWatcher::new_for_testing);
let detected_repos_handle = app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
let (tx, _rx) = async_channel::unbounded();
let skill_watcher_handle = app.add_model(|ctx| SkillWatcher::new_for_testing(ctx, tx));
let temp_dir = TempDir::new().unwrap();
let canonical_repo = StandardizedPath::from_local_canonicalized(temp_dir.path()).unwrap();
detected_repos_handle.update(&mut app, |repos, _| {
repos.insert_test_repo_root(canonical_repo.clone());
});
// Create a regular file (not a directory, not a skill file) on disk.
let readme = temp_dir.path().join("README.md");
fs::write(&readme, "# Project").unwrap();
let update = RepositoryUpdate {
added: HashSet::from([TargetFile::new(readme, false)]),
modified: HashSet::new(),
deleted: HashSet::new(),
moved: HashMap::new(),
commit_updated: false,
index_lock_detected: false,
};
skill_watcher_handle.update(&mut app, |skill_watcher, ctx| {
skill_watcher.handle_repository_update(&update, ctx);
});
// A plain file being added must NOT queue a project directory scan.
skill_watcher_handle.read(&app, |watcher, _| {
assert_eq!(watcher.queued_project_directory_creations.len(), 0);
});
});
}
// ============================================================================
// Tests for handle_queued_project_directory_creations
// ============================================================================