feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
317
crates/infrastructure/tests/agent_runtime.rs
Normal file
317
crates/infrastructure/tests/agent_runtime.rs
Normal file
@ -0,0 +1,317 @@
|
||||
//! L5 tests for [`CliAgentRuntime`].
|
||||
//!
|
||||
//! Covers:
|
||||
//! - `prepare_invocation` (the **pure** core): for every [`ContextInjection`]
|
||||
//! strategy, the produced [`SpawnSpec`] (command, args order, resolved cwd,
|
||||
//! `context_plan`) is asserted.
|
||||
//! - `detection_spec` (pure): custom `detect` tokenisation vs `--version`
|
||||
//! fallback.
|
||||
//! - `detect` driven by a **mocked** [`ProcessSpawner`]: exit 0 ⇒ `true`,
|
||||
//! non-zero ⇒ `false`, spawner error ⇒ propagated as `RuntimeError`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{
|
||||
AgentRuntime, ContextInjectionPlan, ExitStatus, Output, PreparedContext, ProcessError,
|
||||
ProcessSpawner, RuntimeError, SpawnSpec,
|
||||
};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::ids::ProfileId;
|
||||
use domain::MarkdownDoc;
|
||||
use infrastructure::CliAgentRuntime;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn profile(injection: ContextInjection, cwd_template: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||
"Test",
|
||||
"mycli",
|
||||
vec!["--static".to_owned(), "arg".to_owned()],
|
||||
injection,
|
||||
Some("mycli probe --json".to_owned()),
|
||||
cwd_template,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn ctx() -> PreparedContext {
|
||||
PreparedContext {
|
||||
content: MarkdownDoc::new("# hi"),
|
||||
relative_path: ".ideai/agent.md".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`ProcessSpawner`] that returns a fixed outcome regardless of the spec.
|
||||
struct FixedSpawner(Result<Output, ProcessError>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProcessSpawner for FixedSpawner {
|
||||
async fn run(&self, _spec: SpawnSpec) -> Result<Output, ProcessError> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_with(outcome: Result<Output, ProcessError>) -> CliAgentRuntime {
|
||||
CliAgentRuntime::new(Arc::new(FixedSpawner(outcome)))
|
||||
}
|
||||
|
||||
/// A spawner that just records the spec it was handed (for detect-spec assertions).
|
||||
struct RecordingSpawner(std::sync::Mutex<Option<SpawnSpec>>);
|
||||
|
||||
#[async_trait]
|
||||
impl ProcessSpawner for RecordingSpawner {
|
||||
async fn run(&self, spec: SpawnSpec) -> Result<Output, ProcessError> {
|
||||
*self.0.lock().unwrap() = Some(spec);
|
||||
Ok(Output {
|
||||
status: ExitStatus { code: Some(0) },
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn pure_runtime() -> CliAgentRuntime {
|
||||
runtime_with(Ok(Output {
|
||||
status: ExitStatus { code: Some(0) },
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepare_invocation — ConventionFile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn prepare_convention_file_keeps_args_and_plans_file() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
"{projectRoot}",
|
||||
);
|
||||
let root = ProjectPath::new("/home/me/proj").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
|
||||
assert_eq!(spec.command, "mycli");
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged");
|
||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
|
||||
assert_eq!(
|
||||
spec.context_plan,
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepare_invocation — Flag with {path}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn prepare_flag_with_path_substitutes_and_splits() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(
|
||||
ContextInjection::flag("--context-file {path}").unwrap(),
|
||||
"{projectRoot}",
|
||||
);
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
|
||||
// static args first, then the substituted+split flag args.
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
vec!["--static", "arg", "--context-file", ".ideai/agent.md"]
|
||||
);
|
||||
assert_eq!(
|
||||
spec.context_plan,
|
||||
Some(ContextInjectionPlan::Args {
|
||||
args: vec!["--context-file".to_owned(), ".ideai/agent.md".to_owned()]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepare_invocation — Flag without {path} (switch + path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn prepare_flag_without_path_is_switch_then_path() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(ContextInjection::flag("-f").unwrap(), "{projectRoot}");
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg", "-f", ".ideai/agent.md"]);
|
||||
assert_eq!(
|
||||
spec.context_plan,
|
||||
Some(ContextInjectionPlan::Args {
|
||||
args: vec!["-f".to_owned(), ".ideai/agent.md".to_owned()]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepare_invocation — Stdin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn prepare_stdin_keeps_args_and_plans_stdin() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for stdin");
|
||||
assert_eq!(spec.context_plan, Some(ContextInjectionPlan::Stdin));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepare_invocation — Env
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn prepare_env_keeps_args_and_plans_env() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(
|
||||
ContextInjection::env("AGENT_CONTEXT").unwrap(),
|
||||
"{projectRoot}",
|
||||
);
|
||||
let root = ProjectPath::new("/p").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
|
||||
assert_eq!(spec.args, vec!["--static", "arg"], "args unchanged for env");
|
||||
assert_eq!(
|
||||
spec.context_plan,
|
||||
Some(ContextInjectionPlan::Env {
|
||||
var: "AGENT_CONTEXT".to_owned()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prepare_invocation — cwd template substitution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn prepare_substitutes_project_root_in_cwd_template() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(
|
||||
ContextInjection::stdin(),
|
||||
"{projectRoot}/subdir",
|
||||
);
|
||||
let root = ProjectPath::new("/home/me/proj").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj/subdir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_empty_cwd_template_defaults_to_root() {
|
||||
let rt = pure_runtime();
|
||||
let p = profile(ContextInjection::stdin(), "");
|
||||
let root = ProjectPath::new("/home/me/proj").unwrap();
|
||||
|
||||
let spec = rt.prepare_invocation(&p, &ctx(), &root).unwrap();
|
||||
assert_eq!(spec.cwd.as_str(), "/home/me/proj");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detection_spec (pure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detection_spec_uses_custom_detect_tokenised() {
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
let spec = CliAgentRuntime::detection_spec(&p).unwrap();
|
||||
assert_eq!(spec.command, "mycli");
|
||||
assert_eq!(spec.args, vec!["probe", "--json"]);
|
||||
assert!(spec.context_plan.is_none());
|
||||
assert!(spec.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_spec_falls_back_to_command_version() {
|
||||
let p = AgentProfile::new(
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(2)),
|
||||
"NoDetect",
|
||||
"somecli",
|
||||
Vec::new(),
|
||||
ContextInjection::stdin(),
|
||||
None,
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let spec = CliAgentRuntime::detection_spec(&p).unwrap();
|
||||
assert_eq!(spec.command, "somecli");
|
||||
assert_eq!(spec.args, vec!["--version"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// detect (mocked spawner)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn detect_true_on_exit_zero() {
|
||||
let rt = runtime_with(Ok(Output {
|
||||
status: ExitStatus { code: Some(0) },
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}));
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
assert!(rt.detect(&p).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_false_on_nonzero_exit() {
|
||||
let rt = runtime_with(Ok(Output {
|
||||
status: ExitStatus { code: Some(127) },
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}));
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
assert!(!rt.detect(&p).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_false_on_signal_terminated() {
|
||||
let rt = runtime_with(Ok(Output {
|
||||
status: ExitStatus { code: None },
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
}));
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
assert!(!rt.detect(&p).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_propagates_spawner_error() {
|
||||
let rt = runtime_with(Err(ProcessError::Spawn("no such file".to_owned())));
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
let err = rt.detect(&p).expect_err("spawner error surfaces");
|
||||
assert!(matches!(err, RuntimeError::Detection(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_runs_the_detection_spec_command() {
|
||||
let recorder = Arc::new(RecordingSpawner(std::sync::Mutex::new(None)));
|
||||
let rt = CliAgentRuntime::new(recorder.clone());
|
||||
let p = profile(ContextInjection::stdin(), "{projectRoot}");
|
||||
|
||||
rt.detect(&p).unwrap();
|
||||
|
||||
let spec = recorder.0.lock().unwrap().clone().expect("spec recorded");
|
||||
assert_eq!(spec.command, "mycli");
|
||||
assert_eq!(spec.args, vec!["probe", "--json"]);
|
||||
}
|
||||
147
crates/infrastructure/tests/context_store.rs
Normal file
147
crates/infrastructure/tests/context_store.rs
Normal file
@ -0,0 +1,147 @@
|
||||
//! L6 integration tests for [`IdeaiContextStore`] against a real temp directory
|
||||
//! and a real [`LocalFileSystem`], exercising the full `.ideai/` persistence path
|
||||
//! (manifest JSON, context `.md` round-trip, tolerant reads, NotFound).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::ids::{AgentId, ProfileId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{AgentContextStore, FileSystem, RemotePath, StoreError};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use infrastructure::{IdeaiContextStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l6-ctx-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn root(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn child(&self, rel: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store() -> IdeaiContextStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
IdeaiContextStore::new(fs)
|
||||
}
|
||||
|
||||
fn project(root: &str) -> Project {
|
||||
Project::new(
|
||||
domain::ids::ProjectId::new_random(),
|
||||
"demo",
|
||||
ProjectPath::new(root).unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn pid(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn agent(id: AgentId, name: &str, md: &str, profile: ProfileId) -> Agent {
|
||||
Agent::new(id, name, md, profile, AgentOrigin::Scratch, false).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_manifest_loads_empty() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store();
|
||||
let manifest = store.load_manifest(&project(&tmp.root())).await.unwrap();
|
||||
assert!(manifest.entries.is_empty());
|
||||
assert_eq!(manifest.version, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_save_then_load_roundtrips() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store();
|
||||
let p = project(&tmp.root());
|
||||
|
||||
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
|
||||
store.save_manifest(&p, &manifest).await.unwrap();
|
||||
|
||||
let back = store.load_manifest(&p).await.unwrap();
|
||||
assert_eq!(back, manifest);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn context_write_then_read_roundtrips() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store();
|
||||
let p = project(&tmp.root());
|
||||
|
||||
// The manifest must know the agent before its context can be addressed.
|
||||
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
|
||||
store.save_manifest(&p, &manifest).await.unwrap();
|
||||
|
||||
let md = MarkdownDoc::new("# Backend\nYou are the backend agent.");
|
||||
store.write_context(&p, &a.id, &md).await.unwrap();
|
||||
|
||||
let back = store.read_context(&p, &a.id).await.unwrap();
|
||||
assert_eq!(back, md);
|
||||
|
||||
// The `.md` actually landed at `.ideai/agents/backend.md`.
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs
|
||||
.read(&tmp.child(".ideai/agents/backend.md"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(bytes).unwrap(), md.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_context_for_unknown_agent_is_not_found() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store();
|
||||
let p = project(&tmp.root());
|
||||
let err = store.read_context(&p, &aid(404)).await.unwrap_err();
|
||||
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manifest_file_is_camelcase_json_under_ideai() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store();
|
||||
let p = project(&tmp.root());
|
||||
|
||||
let a = agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
let manifest = AgentManifest::new(1, vec![ManifestEntry::from_agent(&a)]).unwrap();
|
||||
store.save_manifest(&p, &manifest).await.unwrap();
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
let agents = json
|
||||
.get("agents")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("top-level `agents` array");
|
||||
assert_eq!(agents.len(), 1);
|
||||
let entry = &agents[0];
|
||||
assert_eq!(entry.get("mdPath").and_then(|v| v.as_str()), Some("agents/backend.md"));
|
||||
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend"));
|
||||
assert!(entry.get("profileId").is_some(), "camelCase profileId present");
|
||||
assert!(entry.get("md_path").is_none(), "no snake_case leak");
|
||||
}
|
||||
53
crates/infrastructure/tests/eventbus.rs
Normal file
53
crates/infrastructure/tests/eventbus.rs
Normal file
@ -0,0 +1,53 @@
|
||||
//! L1 tests for [`TokioBroadcastEventBus`]: a published [`DomainEvent`] is
|
||||
//! received both through the blocking `subscribe()` [`EventStream`] and through
|
||||
//! the async `raw_receiver()` used by the Tauri event relay.
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::EventBus;
|
||||
use domain::ProjectId;
|
||||
use infrastructure::TokioBroadcastEventBus;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn sample_event() -> DomainEvent {
|
||||
DomainEvent::ProjectCreated {
|
||||
project_id: ProjectId::from_uuid(Uuid::nil()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_receiver_gets_published_event() {
|
||||
let bus = TokioBroadcastEventBus::new();
|
||||
let mut rx = bus.raw_receiver();
|
||||
|
||||
bus.publish(sample_event());
|
||||
|
||||
let got = rx.recv().await.expect("event received");
|
||||
assert_eq!(got, sample_event());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_receiver_fans_out_to_multiple_subscribers() {
|
||||
let bus = TokioBroadcastEventBus::new();
|
||||
let mut rx1 = bus.raw_receiver();
|
||||
let mut rx2 = bus.raw_receiver();
|
||||
|
||||
bus.publish(sample_event());
|
||||
|
||||
assert_eq!(rx1.recv().await.unwrap(), sample_event());
|
||||
assert_eq!(rx2.recv().await.unwrap(), sample_event());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_blocking_stream_yields_published_event() {
|
||||
let bus = TokioBroadcastEventBus::new();
|
||||
let mut stream = bus.subscribe();
|
||||
bus.publish(sample_event());
|
||||
assert_eq!(stream.next(), Some(sample_event()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_without_subscribers_is_noop() {
|
||||
let bus = TokioBroadcastEventBus::new();
|
||||
// No receiver registered: publish must not panic.
|
||||
bus.publish(sample_event());
|
||||
}
|
||||
108
crates/infrastructure/tests/git_repository.rs
Normal file
108
crates/infrastructure/tests/git_repository.rs
Normal file
@ -0,0 +1,108 @@
|
||||
//! L8 integration tests for [`Git2Repository`] against a real temporary repo,
|
||||
//! exercising the local flow end to end: init → status → stage → commit →
|
||||
//! branch/current_branch → log, plus the not-a-repo error path.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use domain::ports::GitPort;
|
||||
use domain::ports::GitError;
|
||||
use domain::project::ProjectPath;
|
||||
use infrastructure::Git2Repository;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l8-git-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn root(&self) -> ProjectPath {
|
||||
ProjectPath::new(self.0.to_string_lossy().into_owned()).unwrap()
|
||||
}
|
||||
fn write(&self, name: &str, content: &str) {
|
||||
std::fs::write(self.0.join(name), content).unwrap();
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_status_stage_commit_branch_log_flow() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let git = Git2Repository::new();
|
||||
|
||||
git.init(&root).await.expect("init");
|
||||
|
||||
// A new untracked file shows up as not-staged.
|
||||
tmp.write("a.txt", "hello");
|
||||
let status = git.status(&root).await.unwrap();
|
||||
let a = status
|
||||
.iter()
|
||||
.find(|s| s.path == "a.txt")
|
||||
.expect("a.txt appears in status");
|
||||
assert!(!a.staged, "untracked file is not staged");
|
||||
|
||||
// Staging flips the staged flag.
|
||||
git.stage(&root, "a.txt").await.unwrap();
|
||||
let staged = git.status(&root).await.unwrap();
|
||||
assert!(
|
||||
staged.iter().find(|s| s.path == "a.txt").unwrap().staged,
|
||||
"file is staged after stage()"
|
||||
);
|
||||
|
||||
// Commit the index.
|
||||
let commit = git.commit(&root, "first commit").await.unwrap();
|
||||
assert!(!commit.hash.is_empty());
|
||||
assert_eq!(commit.summary, "first commit");
|
||||
|
||||
// After committing, the tree is clean.
|
||||
assert!(
|
||||
git.status(&root).await.unwrap().is_empty(),
|
||||
"no changes after commit"
|
||||
);
|
||||
|
||||
// A current branch exists and is listed among local branches.
|
||||
let current = git.current_branch(&root).await.unwrap();
|
||||
let current = current.expect("a current branch after the first commit");
|
||||
let branches = git.branches(&root).await.unwrap();
|
||||
assert!(branches.contains(¤t), "current branch is listed");
|
||||
|
||||
// The log has exactly our commit.
|
||||
let log = git.log(&root, 10).await.unwrap();
|
||||
assert_eq!(log.len(), 1);
|
||||
assert_eq!(log[0].summary, "first commit");
|
||||
assert_eq!(log[0].hash, commit.hash);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_on_non_repo_is_not_found() {
|
||||
let tmp = TempDir::new();
|
||||
let err = Git2Repository::new().status(&tmp.root()).await.unwrap_err();
|
||||
assert!(matches!(err, GitError::NotFound), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unstage_after_first_commit_resets_index() {
|
||||
let tmp = TempDir::new();
|
||||
let root = tmp.root();
|
||||
let git = Git2Repository::new();
|
||||
git.init(&root).await.unwrap();
|
||||
tmp.write("a.txt", "v1");
|
||||
git.stage(&root, "a.txt").await.unwrap();
|
||||
git.commit(&root, "c1").await.unwrap();
|
||||
|
||||
// Modify + stage, then unstage → the change is no longer staged.
|
||||
tmp.write("a.txt", "v2");
|
||||
git.stage(&root, "a.txt").await.unwrap();
|
||||
assert!(git.status(&root).await.unwrap().iter().any(|s| s.path == "a.txt" && s.staged));
|
||||
|
||||
git.unstage(&root, "a.txt").await.unwrap();
|
||||
let st = git.status(&root).await.unwrap();
|
||||
let a = st.iter().find(|s| s.path == "a.txt").unwrap();
|
||||
assert!(!a.staged, "unstaged change is no longer in the index");
|
||||
}
|
||||
81
crates/infrastructure/tests/local_fs.rs
Normal file
81
crates/infrastructure/tests/local_fs.rs
Normal file
@ -0,0 +1,81 @@
|
||||
//! L1 integration tests for [`LocalFileSystem`] against a real temp directory.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use domain::ports::{FileSystem, FsError, RemotePath};
|
||||
use infrastructure::LocalFileSystem;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l1-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn child(&self, name: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
fn path(&self) -> RemotePath {
|
||||
RemotePath::new(self.0.to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_then_read_roundtrips() {
|
||||
let tmp = TempDir::new();
|
||||
let fs = LocalFileSystem::new();
|
||||
let file = tmp.child("hello.txt");
|
||||
|
||||
fs.write(&file, b"bonjour").await.unwrap();
|
||||
let back = fs.read(&file).await.unwrap();
|
||||
assert_eq!(back, b"bonjour");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exists_reflects_presence() {
|
||||
let tmp = TempDir::new();
|
||||
let fs = LocalFileSystem::new();
|
||||
let file = tmp.child("maybe.txt");
|
||||
|
||||
assert!(!fs.exists(&file).await.unwrap());
|
||||
fs.write(&file, b"x").await.unwrap();
|
||||
assert!(fs.exists(&file).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_dir_all_and_list() {
|
||||
let tmp = TempDir::new();
|
||||
let fs = LocalFileSystem::new();
|
||||
|
||||
let nested = tmp.child("a/b/c");
|
||||
fs.create_dir_all(&nested).await.unwrap();
|
||||
assert!(fs.exists(&nested).await.unwrap());
|
||||
|
||||
// Put a file and a dir at the top level, then list them.
|
||||
fs.write(&tmp.child("file.txt"), b"y").await.unwrap();
|
||||
let entries = fs.list(&tmp.path()).await.unwrap();
|
||||
|
||||
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(names.contains(&"file.txt"));
|
||||
assert!(names.contains(&"a"));
|
||||
|
||||
let dir_entry = entries.iter().find(|e| e.name == "a").unwrap();
|
||||
assert!(dir_entry.is_dir);
|
||||
let file_entry = entries.iter().find(|e| e.name == "file.txt").unwrap();
|
||||
assert!(!file_entry.is_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_missing_maps_to_not_found() {
|
||||
let tmp = TempDir::new();
|
||||
let fs = LocalFileSystem::new();
|
||||
let err = fs.read(&tmp.child("nope.txt")).await.unwrap_err();
|
||||
assert!(matches!(err, FsError::NotFound(_)), "got {err:?}");
|
||||
}
|
||||
169
crates/infrastructure/tests/profile_store.rs
Normal file
169
crates/infrastructure/tests/profile_store.rs
Normal file
@ -0,0 +1,169 @@
|
||||
//! L5 integration tests for [`FsProfileStore`] against a real temp directory,
|
||||
//! using a real [`LocalFileSystem`] so the full persistence path (camelCase
|
||||
//! `profiles.json`, upsert, delete, first-run marker) is exercised end-to-end.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use infrastructure::{FsProfileStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l5-profile-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn child(&self, name: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store(tmp: &TempDir) -> FsProfileStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsProfileStore::new(fs, tmp.app_data_dir())
|
||||
}
|
||||
|
||||
fn sample(id: u128, name: &str, command: &str) -> AgentProfile {
|
||||
AgentProfile::new(
|
||||
ProfileId::from_uuid(Uuid::from_u128(id)),
|
||||
name,
|
||||
command,
|
||||
Vec::new(),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(format!("{command} --version")),
|
||||
"{projectRoot}",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_list_roundtrips() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let p = sample(1, "Claude", "claude");
|
||||
store.save(&p).await.unwrap();
|
||||
|
||||
let listed = store.list().await.unwrap();
|
||||
assert_eq!(listed, vec![p]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_upserts_by_id_without_duplicating() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let first = sample(1, "before", "claude");
|
||||
store.save(&first).await.unwrap();
|
||||
|
||||
let updated = sample(1, "after", "claude-renamed");
|
||||
store.save(&updated).await.unwrap();
|
||||
|
||||
let listed = store.list().await.unwrap();
|
||||
assert_eq!(listed.len(), 1, "upsert must not duplicate by id");
|
||||
assert_eq!(listed[0], updated);
|
||||
assert_eq!(listed[0].name, "after");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_profile() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let a = sample(1, "A", "a");
|
||||
let b = sample(2, "B", "b");
|
||||
store.save(&a).await.unwrap();
|
||||
store.save(&b).await.unwrap();
|
||||
|
||||
store.delete(a.id).await.unwrap();
|
||||
|
||||
let listed = store.list().await.unwrap();
|
||||
assert_eq!(listed, vec![b]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_is_not_found() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
store.save(&sample(1, "A", "a")).await.unwrap();
|
||||
|
||||
let err = store
|
||||
.delete(ProfileId::from_uuid(Uuid::from_u128(999)))
|
||||
.await
|
||||
.expect_err("deleting unknown id fails");
|
||||
assert!(matches!(err, StoreError::NotFound), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_configured_false_before_any_write() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
// First run: no profiles.json yet.
|
||||
assert!(!store.is_configured().await.unwrap());
|
||||
assert!(store.list().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_configured_true_after_save() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
store.save(&sample(1, "A", "a")).await.unwrap();
|
||||
assert!(store.is_configured().await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_configured_creates_file_with_empty_profiles() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
assert!(!store.is_configured().await.unwrap());
|
||||
store.mark_configured().await.unwrap();
|
||||
assert!(store.is_configured().await.unwrap(), "marker materialised");
|
||||
assert!(
|
||||
store.list().await.unwrap().is_empty(),
|
||||
"empty profile list recorded"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn profiles_file_is_camelcase_versioned() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let p = sample(1, "Claude", "claude");
|
||||
store.save(&p).await.unwrap();
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child("profiles.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert_eq!(json["version"], 1);
|
||||
let profiles = json
|
||||
.get("profiles")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("top-level `profiles` array");
|
||||
assert_eq!(profiles.len(), 1);
|
||||
|
||||
let entry = &profiles[0];
|
||||
assert_eq!(entry["name"], "Claude");
|
||||
assert_eq!(entry["command"], "claude");
|
||||
// camelCase fields, tagged contextInjection.
|
||||
assert!(entry.get("cwdTemplate").is_some(), "camelCase cwdTemplate");
|
||||
assert!(entry.get("cwd_template").is_none(), "no snake_case leak");
|
||||
assert_eq!(entry["contextInjection"]["strategy"], "conventionFile");
|
||||
assert_eq!(entry["contextInjection"]["target"], "CLAUDE.md");
|
||||
}
|
||||
139
crates/infrastructure/tests/project_store.rs
Normal file
139
crates/infrastructure/tests/project_store.rs
Normal file
@ -0,0 +1,139 @@
|
||||
//! L2 integration tests for [`FsProjectStore`] against a real temp directory,
|
||||
//! using a real [`LocalFileSystem`] so the full persistence path (JSON layout,
|
||||
//! tolerant reads, upsert) is exercised end-to-end.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::ProjectId;
|
||||
use domain::layout::Workspace;
|
||||
use domain::ports::{FileSystem, ProjectStore, RemotePath};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use infrastructure::{FsProjectStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A unique scratch directory under the OS temp dir, cleaned up on drop.
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l2-store-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
/// The app-data dir as a plain string, as the composition root would pass it.
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn child(&self, name: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(name).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store(tmp: &TempDir) -> FsProjectStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsProjectStore::new(fs, tmp.app_data_dir())
|
||||
}
|
||||
|
||||
fn sample_project(id: ProjectId, name: &str, root: &str) -> Project {
|
||||
Project::new(
|
||||
id,
|
||||
name,
|
||||
ProjectPath::new(root).unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_list_roundtrips() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let p = sample_project(ProjectId::new_random(), "alpha", "/home/me/alpha");
|
||||
store.save_project(&p).await.unwrap();
|
||||
|
||||
let listed = store.list_projects().await.unwrap();
|
||||
assert_eq!(listed, vec![p]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_upserts_by_id_without_duplicating() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let id = ProjectId::new_random();
|
||||
let first = sample_project(id, "before", "/home/me/proj");
|
||||
store.save_project(&first).await.unwrap();
|
||||
|
||||
// Same id, changed fields: must update in place, not append.
|
||||
let updated = sample_project(id, "after", "/home/me/proj-renamed");
|
||||
store.save_project(&updated).await.unwrap();
|
||||
|
||||
let listed = store.list_projects().await.unwrap();
|
||||
assert_eq!(listed.len(), 1, "upsert must not duplicate by id");
|
||||
assert_eq!(listed[0], updated);
|
||||
assert_eq!(listed[0].name, "after");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_registry_lists_empty() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
// No projects.json written yet: tolerant read returns an empty list.
|
||||
let listed = store.list_projects().await.unwrap();
|
||||
assert!(listed.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_save_then_load_roundtrips() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
// Missing workspace returns the default.
|
||||
let loaded = store.load_workspace().await.unwrap();
|
||||
assert_eq!(loaded, Workspace::default());
|
||||
|
||||
let ws = Workspace::default();
|
||||
store.save_workspace(&ws).await.unwrap();
|
||||
let back = store.load_workspace().await.unwrap();
|
||||
assert_eq!(back, ws);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn registry_file_is_camelcase_json() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let p = sample_project(ProjectId::new_random(), "jsoncheck", "/srv/app");
|
||||
store.save_project(&p).await.unwrap();
|
||||
|
||||
// Read the raw bytes the store wrote and assert the camelCase shape.
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child("projects.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
|
||||
assert!(json.get("version").is_some(), "top-level `version` present");
|
||||
let projects = json
|
||||
.get("projects")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("top-level `projects` array");
|
||||
assert_eq!(projects.len(), 1);
|
||||
|
||||
let entry = &projects[0];
|
||||
// camelCase serialization of `created_at`.
|
||||
assert!(
|
||||
entry.get("createdAt").is_some(),
|
||||
"project uses camelCase `createdAt`, got {entry}"
|
||||
);
|
||||
assert!(entry.get("created_at").is_none(), "no snake_case leak");
|
||||
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("jsoncheck"));
|
||||
assert_eq!(entry.get("root").and_then(|v| v.as_str()), Some("/srv/app"));
|
||||
}
|
||||
169
crates/infrastructure/tests/pty_adapter.rs
Normal file
169
crates/infrastructure/tests/pty_adapter.rs
Normal file
@ -0,0 +1,169 @@
|
||||
//! L3 integration tests for [`PortablePtyAdapter`] — exercising a **real** OS
|
||||
//! PTY on Linux. We spawn tiny `/bin/sh` programs whose output is deterministic,
|
||||
//! drain the blocking output stream on a dedicated thread, and assert on the
|
||||
//! bytes / exit code.
|
||||
//!
|
||||
//! Robustness: every blocking drain runs on its own thread joined with a bounded
|
||||
//! timeout so a misbehaving PTY can never hang the test suite/CI.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use domain::ports::{PtyPort, SpawnSpec};
|
||||
use domain::{ProjectPath, PtySize};
|
||||
use infrastructure::PortablePtyAdapter;
|
||||
|
||||
/// Hard ceiling for any single PTY interaction in these tests.
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
fn sh_spec(script: &str) -> SpawnSpec {
|
||||
SpawnSpec {
|
||||
command: "/bin/sh".to_owned(),
|
||||
args: vec!["-c".to_owned(), script.to_owned()],
|
||||
cwd: ProjectPath::new("/").unwrap(),
|
||||
env: Vec::new(),
|
||||
context_plan: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn size() -> PtySize {
|
||||
PtySize::new(24, 80).unwrap()
|
||||
}
|
||||
|
||||
/// Drains an output stream to a single `Vec<u8>` on a worker thread, returning
|
||||
/// the collected bytes or panicking if it does not finish within `TIMEOUT`.
|
||||
fn drain_with_timeout(
|
||||
stream: domain::ports::OutputStream,
|
||||
timeout: Duration,
|
||||
) -> Vec<u8> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut all = Vec::new();
|
||||
for chunk in stream {
|
||||
all.extend_from_slice(&chunk);
|
||||
}
|
||||
let _ = tx.send(all);
|
||||
});
|
||||
let bytes = rx
|
||||
.recv_timeout(timeout)
|
||||
.expect("output stream drained within timeout");
|
||||
worker.join().expect("drain thread joined");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_printf_streams_expected_bytes_and_exits_zero() {
|
||||
let pty = PortablePtyAdapter::new();
|
||||
let handle = pty
|
||||
.spawn(sh_spec("printf hello-pty"), size())
|
||||
.await
|
||||
.expect("spawn succeeds");
|
||||
|
||||
let stream = pty.subscribe_output(&handle).expect("subscribe once");
|
||||
let bytes = drain_with_timeout(stream, TIMEOUT);
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
text.contains("hello-pty"),
|
||||
"expected output to contain 'hello-pty', got {text:?}"
|
||||
);
|
||||
|
||||
// Process already exited; kill collects the status. `sh` exiting cleanly → 0.
|
||||
let status = pty.kill(&handle).await.expect("kill succeeds");
|
||||
assert_eq!(status.code, Some(0), "clean exit reports code 0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_is_echoed_back_through_output_stream() {
|
||||
// `cat` echoes its stdin back to stdout; we feed it a line then close stdin
|
||||
// by killing it, and assert we saw the echoed bytes.
|
||||
let pty = PortablePtyAdapter::new();
|
||||
let handle = pty
|
||||
.spawn(sh_spec("cat"), size())
|
||||
.await
|
||||
.expect("spawn cat");
|
||||
|
||||
let stream = pty.subscribe_output(&handle).expect("subscribe once");
|
||||
|
||||
// Look for the marker on a worker thread, with a timeout, so we don't block
|
||||
// forever if `cat` never echoes.
|
||||
let (found_tx, found_rx) = mpsc::channel();
|
||||
let worker = thread::spawn(move || {
|
||||
let mut all = Vec::new();
|
||||
for chunk in stream {
|
||||
all.extend_from_slice(&chunk);
|
||||
if String::from_utf8_lossy(&all).contains("marker-123") {
|
||||
let _ = found_tx.send(true);
|
||||
// Keep draining until EOF so the thread can exit on kill.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pty.write(&handle, b"marker-123\n").expect("write to cat");
|
||||
|
||||
let found = found_rx
|
||||
.recv_timeout(TIMEOUT)
|
||||
.expect("echoed marker observed within timeout");
|
||||
assert!(found, "cat echoed the written bytes back");
|
||||
|
||||
pty.kill(&handle).await.expect("kill cat");
|
||||
worker.join().expect("drain thread joined after kill");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subscribe_output_twice_is_an_error() {
|
||||
let pty = PortablePtyAdapter::new();
|
||||
let handle = pty
|
||||
.spawn(sh_spec("sleep 0.2"), size())
|
||||
.await
|
||||
.expect("spawn");
|
||||
|
||||
let first = pty.subscribe_output(&handle);
|
||||
assert!(first.is_ok(), "first subscribe succeeds");
|
||||
|
||||
let second = pty.subscribe_output(&handle);
|
||||
assert!(
|
||||
second.is_err(),
|
||||
"second subscribe on the same session must error"
|
||||
);
|
||||
|
||||
// Drain the first stream so the reader thread can finish, then tidy up.
|
||||
let stream = first.unwrap();
|
||||
drain_with_timeout(stream, TIMEOUT);
|
||||
let _ = pty.kill(&handle).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_resize_kill_on_unknown_handle_are_not_found() {
|
||||
use domain::ports::{PtyError, PtyHandle};
|
||||
use domain::SessionId;
|
||||
|
||||
let pty = PortablePtyAdapter::new();
|
||||
let ghost = PtyHandle {
|
||||
session_id: SessionId::new_random(),
|
||||
};
|
||||
|
||||
assert_eq!(pty.write(&ghost, b"x"), Err(PtyError::NotFound));
|
||||
assert_eq!(pty.resize(&ghost, size()), Err(PtyError::NotFound));
|
||||
assert!(pty.subscribe_output(&ghost).is_err());
|
||||
assert_eq!(pty.kill(&ghost).await, Err(PtyError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_on_live_pty_succeeds() {
|
||||
let pty = PortablePtyAdapter::new();
|
||||
let handle = pty
|
||||
.spawn(sh_spec("sleep 0.2"), size())
|
||||
.await
|
||||
.expect("spawn");
|
||||
|
||||
pty.resize(&handle, PtySize::new(40, 120).unwrap())
|
||||
.expect("resize a live pty succeeds");
|
||||
|
||||
// Drain + reap so the test leaves no live process/thread behind.
|
||||
let stream = pty.subscribe_output(&handle).expect("subscribe");
|
||||
let _ = thread::spawn(move || stream.count());
|
||||
let _ = pty.kill(&handle).await;
|
||||
}
|
||||
70
crates/infrastructure/tests/remote_host.rs
Normal file
70
crates/infrastructure/tests/remote_host.rs
Normal file
@ -0,0 +1,70 @@
|
||||
//! L9 tests for the local remote-host strategy and the host selector.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{RemoteError, RemoteHost, RemotePath};
|
||||
use domain::remote::{RemoteKind, RemoteRef, SshAuth};
|
||||
use infrastructure::{remote_host, LocalHost};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l9-host-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn path(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_host_connects_and_exposes_local_fs() {
|
||||
let tmp = TempDir::new();
|
||||
let host = LocalHost::new();
|
||||
assert_eq!(host.kind(), RemoteKind::Local);
|
||||
host.connect().await.expect("local connect is a no-op");
|
||||
|
||||
let fs = host.file_system();
|
||||
assert!(fs.exists(&RemotePath::new(tmp.path())).await.unwrap());
|
||||
assert!(!fs
|
||||
.exists(&RemotePath::new(format!("{}/nope", tmp.path())))
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selector_builds_local_host() {
|
||||
let host = remote_host(&RemoteRef::local()).expect("local host builds");
|
||||
assert_eq!(host.kind(), RemoteKind::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_rejects_ssh_and_wsl_for_now() {
|
||||
let ssh = RemoteRef::ssh("h", 22, "u", SshAuth::Agent, "/srv").unwrap();
|
||||
assert!(matches!(
|
||||
remote_host(&ssh),
|
||||
Err(RemoteError::Connection(_))
|
||||
));
|
||||
let wsl = RemoteRef::wsl("Ubuntu").unwrap();
|
||||
assert!(matches!(
|
||||
remote_host(&wsl),
|
||||
Err(RemoteError::Connection(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Local host PTY/spawner handles are cloneable port objects (Arc-backed).
|
||||
#[test]
|
||||
fn local_host_hands_out_ports() {
|
||||
let host: Arc<dyn RemoteHost> = Arc::new(LocalHost::new());
|
||||
let _fs = host.file_system();
|
||||
let _sp = host.process_spawner();
|
||||
let _pty = host.pty();
|
||||
}
|
||||
139
crates/infrastructure/tests/template_store.rs
Normal file
139
crates/infrastructure/tests/template_store.rs
Normal file
@ -0,0 +1,139 @@
|
||||
//! L7 integration tests for [`FsTemplateStore`] against a real temp directory and
|
||||
//! a real [`LocalFileSystem`]: md + `index.json` round-trip, version persistence,
|
||||
//! upsert, delete, tolerant reads, and the on-disk layout (`templates/md/<id>.md`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ids::{ProfileId, TemplateId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
|
||||
use domain::template::AgentTemplate;
|
||||
use infrastructure::{FsTemplateStore, LocalFileSystem};
|
||||
use uuid::Uuid;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let p = std::env::temp_dir().join(format!("idea-l7-tpl-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
Self(p)
|
||||
}
|
||||
fn app_data_dir(&self) -> String {
|
||||
self.0.to_string_lossy().into_owned()
|
||||
}
|
||||
fn child(&self, rel: &str) -> RemotePath {
|
||||
RemotePath::new(self.0.join(rel).to_string_lossy().into_owned())
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn store(tmp: &TempDir) -> FsTemplateStore {
|
||||
let fs: Arc<dyn FileSystem> = Arc::new(LocalFileSystem::new());
|
||||
FsTemplateStore::new(fs, tmp.app_data_dir())
|
||||
}
|
||||
|
||||
fn tid(n: u128) -> TemplateId {
|
||||
TemplateId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn pid(n: u128) -> ProfileId {
|
||||
ProfileId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn template(id: TemplateId, name: &str, content: &str) -> AgentTemplate {
|
||||
AgentTemplate::new(id, name, MarkdownDoc::new(content), pid(1)).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_index_lists_empty() {
|
||||
let tmp = TempDir::new();
|
||||
assert!(store(&tmp).list().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_then_get_and_list_roundtrip() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let t = template(tid(1), "Backend", "# Backend template");
|
||||
store.save(&t).await.unwrap();
|
||||
|
||||
assert_eq!(store.get(tid(1)).await.unwrap(), t);
|
||||
assert_eq!(store.list().await.unwrap(), vec![t.clone()]);
|
||||
|
||||
// The Markdown actually landed at templates/md/<id>.md.
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs
|
||||
.child_read(&tmp, &format!("templates/md/{}.md", tid(1)))
|
||||
.await;
|
||||
assert_eq!(String::from_utf8(bytes).unwrap(), t.content_md.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_upserts_and_persists_bumped_version() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
|
||||
let t0 = template(tid(1), "Backend", "v1");
|
||||
store.save(&t0).await.unwrap();
|
||||
let t1 = t0.with_updated_content(MarkdownDoc::new("v2"));
|
||||
store.save(&t1).await.unwrap();
|
||||
|
||||
let back = store.get(tid(1)).await.unwrap();
|
||||
assert_eq!(back.version.get(), 2, "bumped version persisted");
|
||||
assert_eq!(back.content_md.as_str(), "v2");
|
||||
assert_eq!(store.list().await.unwrap().len(), 1, "upsert, not append");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_unknown_is_not_found() {
|
||||
let tmp = TempDir::new();
|
||||
assert!(matches!(
|
||||
store(&tmp).get(tid(404)).await.unwrap_err(),
|
||||
StoreError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_from_index() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
store.save(&template(tid(1), "T", "x")).await.unwrap();
|
||||
|
||||
store.delete(tid(1)).await.unwrap();
|
||||
assert!(store.list().await.unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
store.delete(tid(1)).await.unwrap_err(),
|
||||
StoreError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_is_camelcase_with_content_hash() {
|
||||
let tmp = TempDir::new();
|
||||
let store = store(&tmp);
|
||||
store.save(&template(tid(1), "Backend", "hello")).await.unwrap();
|
||||
|
||||
let fs = LocalFileSystem::new();
|
||||
let bytes = fs.read(&tmp.child("templates/index.json")).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
let entry = &json.get("templates").unwrap().as_array().unwrap()[0];
|
||||
assert_eq!(entry.get("name").and_then(|v| v.as_str()), Some("Backend"));
|
||||
assert!(entry.get("contentHash").is_some(), "camelCase contentHash present");
|
||||
assert!(entry.get("defaultProfileId").is_some());
|
||||
assert!(entry.get("content_hash").is_none(), "no snake_case leak");
|
||||
}
|
||||
|
||||
/// Tiny read helper so the md-path assertion stays readable.
|
||||
trait ChildRead {
|
||||
async fn child_read(&self, tmp: &TempDir, rel: &str) -> Vec<u8>;
|
||||
}
|
||||
impl ChildRead for LocalFileSystem {
|
||||
async fn child_read(&self, tmp: &TempDir, rel: &str) -> Vec<u8> {
|
||||
self.read(&tmp.child(rel)).await.unwrap()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user