feat(e2e): add test mode infrastructure with BTERMINAL_TEST env isolation
Rust: watcher.rs/fs_watcher.rs skip watchers in test mode, is_test_mode Tauri command. Frontend: wake-scheduler disable, App.svelte test mode detection. AppConfig centralization in bterminal-core (OnceLock pattern for path overrides).
This commit is contained in:
parent
01c8ab8b3e
commit
4b86065163
18 changed files with 346 additions and 29 deletions
1
v2/Cargo.lock
generated
1
v2/Cargo.lock
generated
|
|
@ -263,6 +263,7 @@ dependencies = [
|
||||||
name = "bterminal-core"
|
name = "bterminal-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"dirs 5.0.1",
|
||||||
"log",
|
"log",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ serde_json = "1.0"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
portable-pty = "0.8"
|
portable-pty = "0.8"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
dirs = "5"
|
||||||
|
|
|
||||||
193
v2/bterminal-core/src/config.rs
Normal file
193
v2/bterminal-core/src/config.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
// AppConfig — centralized path resolution for all BTerminal subsystems.
|
||||||
|
// In production, paths resolve via dirs:: crate defaults.
|
||||||
|
// In test mode (BTERMINAL_TEST=1), paths resolve from env var overrides:
|
||||||
|
// BTERMINAL_TEST_DATA_DIR → replaces dirs::data_dir()/bterminal
|
||||||
|
// BTERMINAL_TEST_CONFIG_DIR → replaces dirs::config_dir()/bterminal
|
||||||
|
// BTERMINAL_TEST_CTX_DIR → replaces ~/.claude-context
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
/// Data directory for btmsg.db, sessions.db (default: ~/.local/share/bterminal)
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
/// Config directory for groups.json (default: ~/.config/bterminal)
|
||||||
|
pub config_dir: PathBuf,
|
||||||
|
/// ctx database path (default: ~/.claude-context/context.db)
|
||||||
|
pub ctx_db_path: PathBuf,
|
||||||
|
/// Memora database path (default: ~/.local/share/memora/memories.db)
|
||||||
|
pub memora_db_path: PathBuf,
|
||||||
|
/// Whether we are in test mode
|
||||||
|
pub test_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
/// Build config from environment. In test mode, uses BTERMINAL_TEST_*_DIR env vars.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let test_mode = std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1");
|
||||||
|
|
||||||
|
let data_dir = std::env::var("BTERMINAL_TEST_DATA_DIR")
|
||||||
|
.ok()
|
||||||
|
.filter(|_| test_mode)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("bterminal")
|
||||||
|
});
|
||||||
|
|
||||||
|
let config_dir = std::env::var("BTERMINAL_TEST_CONFIG_DIR")
|
||||||
|
.ok()
|
||||||
|
.filter(|_| test_mode)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("bterminal")
|
||||||
|
});
|
||||||
|
|
||||||
|
let ctx_db_path = std::env::var("BTERMINAL_TEST_CTX_DIR")
|
||||||
|
.ok()
|
||||||
|
.filter(|_| test_mode)
|
||||||
|
.map(|d| PathBuf::from(d).join("context.db"))
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(".claude-context")
|
||||||
|
.join("context.db")
|
||||||
|
});
|
||||||
|
|
||||||
|
let memora_db_path = if test_mode {
|
||||||
|
// In test mode, memora is optional — use data_dir/memora/memories.db
|
||||||
|
data_dir.join("memora").join("memories.db")
|
||||||
|
} else {
|
||||||
|
dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join(".local/share")
|
||||||
|
})
|
||||||
|
.join("memora")
|
||||||
|
.join("memories.db")
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
data_dir,
|
||||||
|
config_dir,
|
||||||
|
ctx_db_path,
|
||||||
|
memora_db_path,
|
||||||
|
test_mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to btmsg.db (shared between btmsg and bttask)
|
||||||
|
pub fn btmsg_db_path(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("btmsg.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to sessions.db
|
||||||
|
pub fn sessions_db_dir(&self) -> &PathBuf {
|
||||||
|
&self.data_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to groups.json
|
||||||
|
pub fn groups_json_path(&self) -> PathBuf {
|
||||||
|
self.config_dir.join("groups.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether running in test mode (BTERMINAL_TEST=1)
|
||||||
|
pub fn is_test_mode(&self) -> bool {
|
||||||
|
self.test_mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_production_paths_use_dirs() {
|
||||||
|
// Without BTERMINAL_TEST=1, paths should use dirs:: defaults
|
||||||
|
std::env::remove_var("BTERMINAL_TEST");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
assert!(!config.is_test_mode());
|
||||||
|
// Should end with "bterminal" for data and config
|
||||||
|
assert!(config.data_dir.ends_with("bterminal"));
|
||||||
|
assert!(config.config_dir.ends_with("bterminal"));
|
||||||
|
assert!(config.ctx_db_path.ends_with("context.db"));
|
||||||
|
assert!(config.memora_db_path.ends_with("memories.db"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_btmsg_db_path() {
|
||||||
|
std::env::remove_var("BTERMINAL_TEST");
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
let path = config.btmsg_db_path();
|
||||||
|
assert!(path.ends_with("btmsg.db"));
|
||||||
|
assert!(path.parent().unwrap().ends_with("bterminal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_groups_json_path() {
|
||||||
|
std::env::remove_var("BTERMINAL_TEST");
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
let path = config.groups_json_path();
|
||||||
|
assert!(path.ends_with("groups.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_test_mode_uses_overrides() {
|
||||||
|
std::env::set_var("BTERMINAL_TEST", "1");
|
||||||
|
std::env::set_var("BTERMINAL_TEST_DATA_DIR", "/tmp/bt-test-data");
|
||||||
|
std::env::set_var("BTERMINAL_TEST_CONFIG_DIR", "/tmp/bt-test-config");
|
||||||
|
std::env::set_var("BTERMINAL_TEST_CTX_DIR", "/tmp/bt-test-ctx");
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
assert!(config.is_test_mode());
|
||||||
|
assert_eq!(config.data_dir, PathBuf::from("/tmp/bt-test-data"));
|
||||||
|
assert_eq!(config.config_dir, PathBuf::from("/tmp/bt-test-config"));
|
||||||
|
assert_eq!(config.ctx_db_path, PathBuf::from("/tmp/bt-test-ctx/context.db"));
|
||||||
|
assert_eq!(config.btmsg_db_path(), PathBuf::from("/tmp/bt-test-data/btmsg.db"));
|
||||||
|
assert_eq!(config.groups_json_path(), PathBuf::from("/tmp/bt-test-config/groups.json"));
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
std::env::remove_var("BTERMINAL_TEST");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_test_mode_without_overrides_uses_defaults() {
|
||||||
|
std::env::set_var("BTERMINAL_TEST", "1");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
assert!(config.is_test_mode());
|
||||||
|
// Without override vars, falls back to dirs:: defaults
|
||||||
|
assert!(config.data_dir.ends_with("bterminal"));
|
||||||
|
|
||||||
|
std::env::remove_var("BTERMINAL_TEST");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_test_mode_memora_in_data_dir() {
|
||||||
|
std::env::set_var("BTERMINAL_TEST", "1");
|
||||||
|
std::env::set_var("BTERMINAL_TEST_DATA_DIR", "/tmp/bt-test-data");
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
assert_eq!(
|
||||||
|
config.memora_db_path,
|
||||||
|
PathBuf::from("/tmp/bt-test-data/memora/memories.db")
|
||||||
|
);
|
||||||
|
|
||||||
|
std::env::remove_var("BTERMINAL_TEST");
|
||||||
|
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod config;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ fn default_provider() -> String {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SidecarConfig {
|
pub struct SidecarConfig {
|
||||||
pub search_paths: Vec<PathBuf>,
|
pub search_paths: Vec<PathBuf>,
|
||||||
|
/// Extra env vars forwarded to sidecar processes (e.g. BTERMINAL_TEST=1 for test isolation)
|
||||||
|
pub env_overrides: std::collections::HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SidecarCommand {
|
struct SidecarCommand {
|
||||||
|
|
@ -94,6 +96,7 @@ impl SidecarManager {
|
||||||
.args(&cmd.args)
|
.args(&cmd.args)
|
||||||
.env_clear()
|
.env_clear()
|
||||||
.envs(clean_env)
|
.envs(clean_env)
|
||||||
|
.envs(self.config.env_overrides.iter().map(|(k, v)| (k.as_str(), v.as_str())))
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,10 @@ async fn main() {
|
||||||
}
|
}
|
||||||
search_paths.push(std::path::PathBuf::from("sidecar"));
|
search_paths.push(std::path::PathBuf::from("sidecar"));
|
||||||
|
|
||||||
let sidecar_config = SidecarConfig { search_paths };
|
let sidecar_config = SidecarConfig {
|
||||||
|
search_paths,
|
||||||
|
env_overrides: std::collections::HashMap::new(),
|
||||||
|
};
|
||||||
let token = Arc::new(cli.token);
|
let token = Arc::new(cli.token);
|
||||||
|
|
||||||
// Rate limiting state for auth failures
|
// Rate limiting state for auth failures
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,27 @@
|
||||||
// btmsg — Access to btmsg SQLite database
|
// btmsg — Access to btmsg SQLite database
|
||||||
// Database at ~/.local/share/bterminal/btmsg.db (created by btmsg CLI)
|
// Database at ~/.local/share/bterminal/btmsg.db (created by btmsg CLI)
|
||||||
|
// Path configurable via init() for test isolation.
|
||||||
|
|
||||||
use rusqlite::{params, Connection, OpenFlags};
|
use rusqlite::{params, Connection, OpenFlags};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static DB_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Set the btmsg database path. Must be called before any db access.
|
||||||
|
/// Called from lib.rs setup with AppConfig-resolved path.
|
||||||
|
pub fn init(path: PathBuf) {
|
||||||
|
let _ = DB_PATH.set(path);
|
||||||
|
}
|
||||||
|
|
||||||
fn db_path() -> PathBuf {
|
fn db_path() -> PathBuf {
|
||||||
dirs::data_dir()
|
DB_PATH.get().cloned().unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
dirs::data_dir()
|
||||||
.join("bterminal")
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join("btmsg.db")
|
.join("bterminal")
|
||||||
|
.join("btmsg.db")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_db() -> Result<Connection, String> {
|
fn open_db() -> Result<Connection, String> {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,27 @@
|
||||||
// bttask — Read access to task board SQLite tables in btmsg.db
|
// bttask — Read access to task board SQLite tables in btmsg.db
|
||||||
// Tasks table created by bttask CLI, shared DB with btmsg
|
// Tasks table created by bttask CLI, shared DB with btmsg
|
||||||
|
// Path configurable via init() for test isolation.
|
||||||
|
|
||||||
use rusqlite::{params, Connection, OpenFlags};
|
use rusqlite::{params, Connection, OpenFlags};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static DB_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Set the bttask database path. Must be called before any db access.
|
||||||
|
/// Called from lib.rs setup with AppConfig-resolved path.
|
||||||
|
pub fn init(path: PathBuf) {
|
||||||
|
let _ = DB_PATH.set(path);
|
||||||
|
}
|
||||||
|
|
||||||
fn db_path() -> PathBuf {
|
fn db_path() -> PathBuf {
|
||||||
dirs::data_dir()
|
DB_PATH.get().cloned().unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
dirs::data_dir()
|
||||||
.join("bterminal")
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join("btmsg.db")
|
.join("bterminal")
|
||||||
|
.join("btmsg.db")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_db() -> Result<Connection, String> {
|
fn open_db() -> Result<Connection, String> {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@ pub fn open_url(url: String) -> Result<(), String> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn is_test_mode() -> bool {
|
||||||
|
std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1")
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn frontend_log(level: String, message: String, context: Option<serde_json::Value>) {
|
pub fn frontend_log(level: String, message: String, context: Option<serde_json::Value>) {
|
||||||
match level.as_str() {
|
match level.as_str() {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
// ctx — Read-only access to the Claude Code context manager database
|
// ctx — Read-only access to the Claude Code context manager database
|
||||||
// Database: ~/.claude-context/context.db (managed by ctx CLI tool)
|
// Database: ~/.claude-context/context.db (managed by ctx CLI tool)
|
||||||
|
// Path configurable via new_with_path() for test isolation.
|
||||||
|
|
||||||
use rusqlite::{Connection, params};
|
use rusqlite::{Connection, params};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|
@ -22,10 +24,11 @@ pub struct CtxSummary {
|
||||||
|
|
||||||
pub struct CtxDb {
|
pub struct CtxDb {
|
||||||
conn: Mutex<Option<Connection>>,
|
conn: Mutex<Option<Connection>>,
|
||||||
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CtxDb {
|
impl CtxDb {
|
||||||
fn db_path() -> std::path::PathBuf {
|
fn default_db_path() -> PathBuf {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.join(".claude-context")
|
.join(".claude-context")
|
||||||
|
|
@ -33,8 +36,11 @@ impl CtxDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let db_path = Self::db_path();
|
Self::new_with_path(Self::default_db_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a CtxDb with a custom database path (for test isolation).
|
||||||
|
pub fn new_with_path(db_path: PathBuf) -> Self {
|
||||||
let conn = if db_path.exists() {
|
let conn = if db_path.exists() {
|
||||||
Connection::open_with_flags(
|
Connection::open_with_flags(
|
||||||
&db_path,
|
&db_path,
|
||||||
|
|
@ -44,12 +50,12 @@ impl CtxDb {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
Self { conn: Mutex::new(conn) }
|
Self { conn: Mutex::new(conn), path: db_path }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the context database directory and schema, then open a read-only connection.
|
/// Create the context database directory and schema, then open a read-only connection.
|
||||||
pub fn init_db(&self) -> Result<(), String> {
|
pub fn init_db(&self) -> Result<(), String> {
|
||||||
let db_path = Self::db_path();
|
let db_path = &self.path;
|
||||||
|
|
||||||
// Create parent directory
|
// Create parent directory
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
|
|
@ -136,7 +142,7 @@ impl CtxDb {
|
||||||
/// Register a project in the ctx database (creates if not exists).
|
/// Register a project in the ctx database (creates if not exists).
|
||||||
/// Opens a brief read-write connection; the main self.conn stays read-only.
|
/// Opens a brief read-write connection; the main self.conn stays read-only.
|
||||||
pub fn register_project(&self, name: &str, description: &str, work_dir: Option<&str>) -> Result<(), String> {
|
pub fn register_project(&self, name: &str, description: &str, work_dir: Option<&str>) -> Result<(), String> {
|
||||||
let db_path = Self::db_path();
|
let db_path = &self.path;
|
||||||
let conn = Connection::open(&db_path)
|
let conn = Connection::open(&db_path)
|
||||||
.map_err(|e| format!("ctx database not found: {e}"))?;
|
.map_err(|e| format!("ctx database not found: {e}"))?;
|
||||||
|
|
||||||
|
|
@ -257,7 +263,7 @@ mod tests {
|
||||||
|
|
||||||
/// Create a CtxDb with conn set to None, simulating a missing database.
|
/// Create a CtxDb with conn set to None, simulating a missing database.
|
||||||
fn make_missing_db() -> CtxDb {
|
fn make_missing_db() -> CtxDb {
|
||||||
CtxDb { conn: Mutex::new(None) }
|
CtxDb { conn: Mutex::new(None), path: PathBuf::from("/nonexistent/context.db") }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,12 @@ impl ProjectFsWatcher {
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
cwd: &str,
|
cwd: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// In test mode, skip inotify watchers to avoid resource contention and flaky events
|
||||||
|
if std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1") {
|
||||||
|
log::info!("Test mode: skipping fs watcher for project {project_id}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let cwd_path = Path::new(cwd);
|
let cwd_path = Path::new(cwd);
|
||||||
if !cwd_path.is_dir() {
|
if !cwd_path.is_dir() {
|
||||||
return Err(format!("Not a directory: {cwd}"));
|
return Err(format!("Not a directory: {cwd}"));
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
// Project group configuration
|
// Project group configuration
|
||||||
// Reads/writes ~/.config/bterminal/groups.json
|
// Reads/writes ~/.config/bterminal/groups.json
|
||||||
|
// Path configurable via init() for test isolation.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static CONFIG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Set the groups.json path. Must be called before any config access.
|
||||||
|
/// Called from lib.rs setup with AppConfig-resolved path.
|
||||||
|
pub fn init(path: PathBuf) {
|
||||||
|
let _ = CONFIG_PATH.set(path);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -62,10 +72,12 @@ impl Default for GroupsFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> PathBuf {
|
fn config_path() -> PathBuf {
|
||||||
dirs::config_dir()
|
CONFIG_PATH.get().cloned().unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
dirs::config_dir()
|
||||||
.join("bterminal")
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join("groups.json")
|
.join("bterminal")
|
||||||
|
.join("groups.json")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_groups() -> Result<GroupsFile, String> {
|
pub fn load_groups() -> Result<GroupsFile, String> {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ mod session;
|
||||||
mod telemetry;
|
mod telemetry;
|
||||||
mod watcher;
|
mod watcher;
|
||||||
|
|
||||||
|
use bterminal_core::config::AppConfig;
|
||||||
use event_sink::TauriEventSink;
|
use event_sink::TauriEventSink;
|
||||||
use pty::PtyManager;
|
use pty::PtyManager;
|
||||||
use remote::RemoteManager;
|
use remote::RemoteManager;
|
||||||
|
|
@ -32,6 +33,7 @@ pub(crate) struct AppState {
|
||||||
pub ctx_db: Arc<ctx::CtxDb>,
|
pub ctx_db: Arc<ctx::CtxDb>,
|
||||||
pub memora_db: Arc<memora::MemoraDb>,
|
pub memora_db: Arc<memora::MemoraDb>,
|
||||||
pub remote_manager: Arc<RemoteManager>,
|
pub remote_manager: Arc<RemoteManager>,
|
||||||
|
pub app_config: Arc<AppConfig>,
|
||||||
_telemetry: telemetry::TelemetryGuard,
|
_telemetry: telemetry::TelemetryGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,9 +42,26 @@ pub fn run() {
|
||||||
// Force dark GTK theme for native dialogs (file chooser, etc.)
|
// Force dark GTK theme for native dialogs (file chooser, etc.)
|
||||||
std::env::set_var("GTK_THEME", "Adwaita:dark");
|
std::env::set_var("GTK_THEME", "Adwaita:dark");
|
||||||
|
|
||||||
|
// Resolve all paths via AppConfig (respects BTERMINAL_TEST_* env vars)
|
||||||
|
let app_config = AppConfig::from_env();
|
||||||
|
if app_config.is_test_mode() {
|
||||||
|
log::info!(
|
||||||
|
"Test mode enabled: data_dir={}, config_dir={}",
|
||||||
|
app_config.data_dir.display(),
|
||||||
|
app_config.config_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize subsystem paths from AppConfig (before any db access)
|
||||||
|
btmsg::init(app_config.btmsg_db_path());
|
||||||
|
bttask::init(app_config.btmsg_db_path());
|
||||||
|
groups::init(app_config.groups_json_path());
|
||||||
|
|
||||||
// Initialize tracing + optional OTLP export (before any tracing macros)
|
// Initialize tracing + optional OTLP export (before any tracing macros)
|
||||||
let telemetry_guard = telemetry::init();
|
let telemetry_guard = telemetry::init();
|
||||||
|
|
||||||
|
let app_config_arc = Arc::new(app_config);
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
// PTY
|
// PTY
|
||||||
|
|
@ -151,6 +170,7 @@ pub fn run() {
|
||||||
// Misc
|
// Misc
|
||||||
commands::misc::cli_get_group,
|
commands::misc::cli_get_group,
|
||||||
commands::misc::open_url,
|
commands::misc::open_url,
|
||||||
|
commands::misc::is_test_mode,
|
||||||
commands::misc::frontend_log,
|
commands::misc::frontend_log,
|
||||||
])
|
])
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
|
@ -161,6 +181,8 @@ pub fn run() {
|
||||||
// tracing's compatibility layer). Adding plugin-log would panic with
|
// tracing's compatibility layer). Adding plugin-log would panic with
|
||||||
// "attempted to set a logger after the logging system was already initialized".
|
// "attempted to set a logger after the logging system was already initialized".
|
||||||
|
|
||||||
|
let config = app_config_arc.clone();
|
||||||
|
|
||||||
// Create TauriEventSink for core managers
|
// Create TauriEventSink for core managers
|
||||||
let sink: Arc<dyn bterminal_core::event::EventSink> =
|
let sink: Arc<dyn bterminal_core::event::EventSink> =
|
||||||
Arc::new(TauriEventSink(app.handle().clone()));
|
Arc::new(TauriEventSink(app.handle().clone()));
|
||||||
|
|
@ -178,28 +200,38 @@ pub fn run() {
|
||||||
.parent()
|
.parent()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
// Forward test mode env vars to sidecar processes
|
||||||
|
let mut env_overrides = std::collections::HashMap::new();
|
||||||
|
if config.is_test_mode() {
|
||||||
|
env_overrides.insert("BTERMINAL_TEST".into(), "1".into());
|
||||||
|
if let Ok(v) = std::env::var("BTERMINAL_TEST_DATA_DIR") {
|
||||||
|
env_overrides.insert("BTERMINAL_TEST_DATA_DIR".into(), v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var("BTERMINAL_TEST_CONFIG_DIR") {
|
||||||
|
env_overrides.insert("BTERMINAL_TEST_CONFIG_DIR".into(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sidecar_config = SidecarConfig {
|
let sidecar_config = SidecarConfig {
|
||||||
search_paths: vec![
|
search_paths: vec![
|
||||||
resource_dir.join("sidecar"),
|
resource_dir.join("sidecar"),
|
||||||
dev_root.join("sidecar"),
|
dev_root.join("sidecar"),
|
||||||
],
|
],
|
||||||
|
env_overrides,
|
||||||
};
|
};
|
||||||
|
|
||||||
let pty_manager = Arc::new(PtyManager::new(sink.clone()));
|
let pty_manager = Arc::new(PtyManager::new(sink.clone()));
|
||||||
let sidecar_manager = Arc::new(SidecarManager::new(sink, sidecar_config));
|
let sidecar_manager = Arc::new(SidecarManager::new(sink, sidecar_config));
|
||||||
|
|
||||||
// Initialize session database
|
// Initialize session database using AppConfig data_dir
|
||||||
let data_dir = dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
||||||
.join("bterminal");
|
|
||||||
let session_db = Arc::new(
|
let session_db = Arc::new(
|
||||||
SessionDb::open(&data_dir).expect("Failed to open session database"),
|
SessionDb::open(config.sessions_db_dir()).expect("Failed to open session database"),
|
||||||
);
|
);
|
||||||
|
|
||||||
let file_watcher = Arc::new(FileWatcherManager::new());
|
let file_watcher = Arc::new(FileWatcherManager::new());
|
||||||
let fs_watcher = Arc::new(ProjectFsWatcher::new());
|
let fs_watcher = Arc::new(ProjectFsWatcher::new());
|
||||||
let ctx_db = Arc::new(ctx::CtxDb::new());
|
let ctx_db = Arc::new(ctx::CtxDb::new_with_path(config.ctx_db_path.clone()));
|
||||||
let memora_db = Arc::new(memora::MemoraDb::new());
|
let memora_db = Arc::new(memora::MemoraDb::new_with_path(config.memora_db_path.clone()));
|
||||||
let remote_manager = Arc::new(RemoteManager::new());
|
let remote_manager = Arc::new(RemoteManager::new());
|
||||||
|
|
||||||
// Start local sidecar
|
// Start local sidecar
|
||||||
|
|
@ -217,6 +249,7 @@ pub fn run() {
|
||||||
ctx_db,
|
ctx_db,
|
||||||
memora_db,
|
memora_db,
|
||||||
remote_manager,
|
remote_manager,
|
||||||
|
app_config: config,
|
||||||
_telemetry: telemetry_guard,
|
_telemetry: telemetry_guard,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ pub struct MemoraDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoraDb {
|
impl MemoraDb {
|
||||||
fn db_path() -> std::path::PathBuf {
|
fn default_db_path() -> std::path::PathBuf {
|
||||||
dirs::data_dir()
|
dirs::data_dir()
|
||||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
|
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
|
||||||
.join("memora")
|
.join("memora")
|
||||||
|
|
@ -34,8 +34,11 @@ impl MemoraDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let db_path = Self::db_path();
|
Self::new_with_path(Self::default_db_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a MemoraDb with a custom database path (for test isolation).
|
||||||
|
pub fn new_with_path(db_path: std::path::PathBuf) -> Self {
|
||||||
let conn = if db_path.exists() {
|
let conn = if db_path.exists() {
|
||||||
Connection::open_with_flags(
|
Connection::open_with_flags(
|
||||||
&db_path,
|
&db_path,
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,11 @@ pub fn init() -> TelemetryGuard {
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
.compact();
|
.compact();
|
||||||
|
|
||||||
|
// In test mode, never export telemetry (avoid contaminating production data)
|
||||||
|
let is_test = std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1");
|
||||||
|
|
||||||
match std::env::var("BTERMINAL_OTLP_ENDPOINT") {
|
match std::env::var("BTERMINAL_OTLP_ENDPOINT") {
|
||||||
Ok(endpoint) if !endpoint.is_empty() => {
|
Ok(endpoint) if !endpoint.is_empty() && !is_test => {
|
||||||
match build_otlp_provider(&endpoint) {
|
match build_otlp_provider(&endpoint) {
|
||||||
Ok(provider) => {
|
Ok(provider) => {
|
||||||
let otel_layer = tracing_opentelemetry::layer()
|
let otel_layer = tracing_opentelemetry::layer()
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@ impl FileWatcherManager {
|
||||||
pane_id: &str,
|
pane_id: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
|
// In test mode, skip file watching to avoid inotify noise and flaky events
|
||||||
|
if std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1") {
|
||||||
|
return std::fs::read_to_string(path)
|
||||||
|
.map_err(|e| format!("Failed to read file: {e}"));
|
||||||
|
}
|
||||||
|
|
||||||
let file_path = PathBuf::from(path);
|
let file_path = PathBuf::from(path);
|
||||||
if !file_path.exists() {
|
if !file_path.exists() {
|
||||||
return Err(format!("File not found: {path}"));
|
return Err(format!("File not found: {path}"));
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
|
import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
|
||||||
import { MemoraAdapter } from './lib/adapters/memora-bridge';
|
import { MemoraAdapter } from './lib/adapters/memora-bridge';
|
||||||
import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte';
|
import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte';
|
||||||
|
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
// Workspace components
|
// Workspace components
|
||||||
import GlobalTabBar from './lib/components/Workspace/GlobalTabBar.svelte';
|
import GlobalTabBar from './lib/components/Workspace/GlobalTabBar.svelte';
|
||||||
|
|
@ -82,6 +84,11 @@
|
||||||
startAgentDispatcher();
|
startAgentDispatcher();
|
||||||
startHealthTick();
|
startHealthTick();
|
||||||
|
|
||||||
|
// Disable wake scheduler in test mode to prevent timer interference
|
||||||
|
invoke<boolean>('is_test_mode').then(isTest => {
|
||||||
|
if (isTest) disableWakeScheduler();
|
||||||
|
});
|
||||||
|
|
||||||
if (!detached) {
|
if (!detached) {
|
||||||
loadWorkspace().then(() => { loaded = true; });
|
loadWorkspace().then(() => { loaded = true; });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,17 @@ export interface WakeEvent {
|
||||||
|
|
||||||
let registrations = $state<Map<string, ManagerRegistration>>(new Map());
|
let registrations = $state<Map<string, ManagerRegistration>>(new Map());
|
||||||
let pendingWakes = $state<Map<string, WakeEvent>>(new Map());
|
let pendingWakes = $state<Map<string, WakeEvent>>(new Map());
|
||||||
|
/** When true, registerManager() becomes a no-op (set in test mode) */
|
||||||
|
let schedulerDisabled = false;
|
||||||
|
|
||||||
// --- Public API ---
|
// --- Public API ---
|
||||||
|
|
||||||
|
/** Disable the wake scheduler (call during app init in test mode) */
|
||||||
|
export function disableWakeScheduler(): void {
|
||||||
|
schedulerDisabled = true;
|
||||||
|
clearWakeScheduler();
|
||||||
|
}
|
||||||
|
|
||||||
/** Register a Manager agent for wake scheduling */
|
/** Register a Manager agent for wake scheduling */
|
||||||
export function registerManager(
|
export function registerManager(
|
||||||
agentId: AgentId,
|
agentId: AgentId,
|
||||||
|
|
@ -48,6 +56,8 @@ export function registerManager(
|
||||||
intervalMin: number,
|
intervalMin: number,
|
||||||
threshold: number,
|
threshold: number,
|
||||||
): void {
|
): void {
|
||||||
|
if (schedulerDisabled) return;
|
||||||
|
|
||||||
// Unregister first to clear any existing timer
|
// Unregister first to clear any existing timer
|
||||||
unregisterManager(agentId);
|
unregisterManager(agentId);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue