diff --git a/src/channels/mod.rs b/src/channels/mod.rs index a717a7d80..f6f7725af 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -856,7 +856,7 @@ fn normalize_cached_channel_turns(turns: Vec) -> Vec { } fn supports_runtime_model_switch(channel_name: &str) -> bool { - matches!(channel_name, "telegram" | "discord") + !channel_name.eq_ignore_ascii_case("cli") } fn parse_runtime_command(channel_name: &str, content: &str) -> Option { @@ -2258,6 +2258,7 @@ async fn handle_runtime_command_if_needed( /// - Grant session and persistent runtime grants /// - Persist to config /// - Clear exclusions + /// /// Returns the approval success message. async fn handle_confirm_tool_approval_side_effects( ctx: &ChannelRuntimeContext, @@ -2300,7 +2301,7 @@ async fn handle_runtime_command_if_needed( /// /// This path confirms only the current pending request and intentionally does /// not persist approval policy changes for normal tools. - async fn handle_pending_runtime_approval_side_effects( + fn handle_pending_runtime_approval_side_effects( ctx: &ChannelRuntimeContext, request_id: &str, tool_name: &str, @@ -2784,8 +2785,7 @@ async fn handle_runtime_command_if_needed( ctx, &request_id, &req.tool_name, - ) - .await; + ); runtime_trace::record_event( "approval_request_approved", Some(source_channel), @@ -5792,7 +5792,6 @@ mod tests { use crate::memory::{Memory, MemoryCategory, SqliteMemory}; use crate::observability::NoopObserver; use crate::providers::{ChatMessage, Provider}; - use crate::security::AutonomyLevel; use crate::tools::{Tool, ToolResult}; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -5864,7 +5863,7 @@ mod tests { } #[test] - fn parse_runtime_command_allows_approval_commands_on_non_model_channels() { + fn parse_runtime_command_allows_runtime_commands_on_non_cli_channels() { assert_eq!( parse_runtime_command("slack", "/approve-request shell"), Some(ChannelRuntimeCommand::RequestToolApproval( @@ -5909,7 +5908,16 @@ mod tests { parse_runtime_command("slack", "/approvals"), Some(ChannelRuntimeCommand::ListApprovals) ); - assert_eq!(parse_runtime_command("slack", "/models"), None); + assert_eq!( + parse_runtime_command("slack", "/models"), + Some(ChannelRuntimeCommand::ShowProviders) + ); + } + + #[test] + fn parse_runtime_command_keeps_model_switch_disabled_on_cli_channel() { + assert_eq!(parse_runtime_command("cli", "/models"), None); + assert_eq!(parse_runtime_command("cli", "/model"), None); } #[test] @@ -6213,7 +6221,7 @@ mod tests { max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(histories)), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -6896,7 +6904,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -6961,14 +6969,6 @@ BTC is currently around $65,000 based on latest tool output."# let mut channels_by_name = HashMap::new(); channels_by_name.insert(channel.name().to_string(), channel); - - let autonomy_cfg = crate::config::AutonomyConfig { - level: AutonomyLevel::Full, - auto_approve: vec!["mock_price".to_string()], - ..crate::config::AutonomyConfig::default() - }; - let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg)); - let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), @@ -6983,7 +6983,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -7035,14 +7035,6 @@ BTC is currently around $65,000 based on latest tool output."# let mut channels_by_name = HashMap::new(); channels_by_name.insert(channel.name().to_string(), channel); - - let autonomy_cfg = crate::config::AutonomyConfig { - level: AutonomyLevel::Full, - auto_approve: vec!["mock_price".to_string()], - ..crate::config::AutonomyConfig::default() - }; - let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg)); - let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), @@ -7057,7 +7049,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -7123,14 +7115,6 @@ BTC is currently around $65,000 based on latest tool output."# let mut channels_by_name = HashMap::new(); channels_by_name.insert(channel.name().to_string(), channel); - - let autonomy_cfg = crate::config::AutonomyConfig { - level: AutonomyLevel::Full, - auto_approve: vec!["mock_price".to_string()], - ..crate::config::AutonomyConfig::default() - }; - let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg)); - let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), @@ -7145,7 +7129,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -7210,14 +7194,6 @@ BTC is currently around $65,000 based on latest tool output."# let mut channels_by_name = HashMap::new(); channels_by_name.insert(channel.name().to_string(), channel); - - let autonomy_cfg = crate::config::AutonomyConfig { - level: AutonomyLevel::Full, - auto_approve: vec!["mock_price".to_string()], - ..crate::config::AutonomyConfig::default() - }; - let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg)); - let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingProvider), @@ -7232,7 +7208,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -7304,7 +7280,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -7356,14 +7332,6 @@ BTC is currently around $65,000 based on latest tool output."# let mut channels_by_name = HashMap::new(); channels_by_name.insert(channel.name().to_string(), channel); - - let autonomy_cfg = crate::config::AutonomyConfig { - level: AutonomyLevel::Full, - auto_approve: vec!["mock_price".to_string()], - ..crate::config::AutonomyConfig::default() - }; - let approval_manager = Arc::new(ApprovalManager::from_config(&autonomy_cfg)); - let runtime_ctx = Arc::new(ChannelRuntimeContext { channels_by_name: Arc::new(channels_by_name), provider: Arc::new(ToolCallingAliasProvider), @@ -7378,7 +7346,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -7454,7 +7422,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -7558,7 +7526,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -7699,7 +7667,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -7788,7 +7756,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -7866,7 +7834,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -8027,7 +7995,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8142,7 +8110,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8252,7 +8220,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8347,7 +8315,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8449,7 +8417,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8549,7 +8517,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8700,7 +8668,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8797,7 +8765,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -8947,7 +8915,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -9067,7 +9035,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -9167,7 +9135,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -9289,7 +9257,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -9409,7 +9377,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -9488,7 +9456,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -9590,7 +9558,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(provider_cache_seed)), @@ -9777,7 +9745,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -9939,7 +9907,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 12, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -10007,7 +9975,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 3, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -10187,7 +10155,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -10277,7 +10245,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -10379,7 +10347,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -10463,7 +10431,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -10532,7 +10500,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 10, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -11163,7 +11131,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -11259,7 +11227,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -11354,7 +11322,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -11453,7 +11421,7 @@ BTC is currently around $65,000 based on latest tool output."# max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(histories)), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -12281,7 +12249,7 @@ BTC is currently around $65,000 based on latest tool output."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), @@ -12357,7 +12325,7 @@ BTC is currently around $65,000 based on latest tool output."#; max_tool_iterations: 5, min_relevance_score: 0.0, conversation_histories: Arc::new(Mutex::new(HashMap::new())), - conversation_locks: Default::default(), + conversation_locks: Arc::default(), session_config: crate::config::AgentSessionConfig::default(), session_manager: None, provider_cache: Arc::new(Mutex::new(HashMap::new())), diff --git a/src/gateway/api.rs b/src/gateway/api.rs index fb263678a..01aef8cac 100644 --- a/src/gateway/api.rs +++ b/src/gateway/api.rs @@ -2,7 +2,7 @@ //! //! All `/api/*` routes require bearer token authentication (PairingGuard). -use super::AppState; +use super::{mock_dashboard, AppState}; use axum::{ extract::{Path, Query, State}, http::{header, HeaderMap, StatusCode}, @@ -76,6 +76,9 @@ pub async fn handle_api_status( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::status(); + } let config = state.config.lock().clone(); let health = crate::health::snapshot(); @@ -110,6 +113,9 @@ pub async fn handle_api_config_get( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::config_get(); + } let config = state.config.lock().clone(); @@ -142,6 +148,9 @@ pub async fn handle_api_config_put( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::config_put(body); + } // Parse the incoming TOML and normalize known dashboard-masked edge cases. let mut incoming_toml: toml::Value = match toml::from_str(&body) { @@ -200,6 +209,9 @@ pub async fn handle_api_tools( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::tools(); + } let tools: Vec = state .tools_registry @@ -224,6 +236,9 @@ pub async fn handle_api_cron_list( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::cron_list(); + } let config = state.config.lock().clone(); match crate::cron::list_jobs(&config) { @@ -261,6 +276,9 @@ pub async fn handle_api_cron_add( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::cron_add(body.name, body.schedule, body.command, None); + } let config = state.config.lock().clone(); let schedule = crate::cron::Schedule::Cron { @@ -296,6 +314,9 @@ pub async fn handle_api_cron_delete( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::cron_delete(&id); + } let config = state.config.lock().clone(); match crate::cron::remove_job(&config, &id) { @@ -316,6 +337,9 @@ pub async fn handle_api_integrations( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::integrations(); + } let config = state.config.lock().clone(); let entries = crate::integrations::registry::all_integrations(); @@ -336,6 +360,342 @@ pub async fn handle_api_integrations( Json(serde_json::json!({"integrations": integrations})).into_response() } +/// GET /api/integrations/settings — detailed settings for each integration +pub async fn handle_api_integrations_settings( + State(state): State, + headers: HeaderMap, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::integrations_settings(); + } + + let config = state.config.lock().clone(); + let entries = crate::integrations::registry::all_integrations(); + + let active_default_provider_id = config + .default_provider + .as_ref() + .and_then(|p| integration_id_from_provider(p)); + + let integrations: Vec = entries + .iter() + .map(|entry| { + let status = (entry.status_fn)(&config); + let (configured, fields) = integration_settings_fields(&config, entry.name); + let activates_default_provider = is_ai_provider(entry.name); + + serde_json::json!({ + "id": integration_name_to_id(entry.name), + "name": entry.name, + "description": entry.description, + "category": entry.category, + "status": status, + "configured": configured, + "activates_default_provider": activates_default_provider, + "fields": fields, + }) + }) + .collect(); + + Json(serde_json::json!({ + "revision": "v1", + "active_default_provider_integration_id": active_default_provider_id, + "integrations": integrations, + })) + .into_response() +} + +/// PUT /api/integrations/:id/credentials — update integration credentials +pub async fn handle_api_integrations_credentials_put( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + if let Err(e) = require_auth(&state, &headers) { + return e.into_response(); + } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::integrations_credentials_put(&id, &body); + } + + let fields = body + .get("fields") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + let mut config = state.config.lock().clone(); + let Some(provider_key) = provider_key_from_integration_id(&id) else { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": format!( + "Integration '{}' does not support credential updates via this endpoint", + id + ) + })), + ) + .into_response(); + }; + + // Apply credential updates based on integration + match provider_key { + "openrouter" | "anthropic" | "openai" | "google" | "deepseek" | "xai" | "mistral" + | "perplexity" | "vercel" | "bedrock" | "groq" | "together" | "cohere" | "fireworks" + | "venice" | "moonshot" | "stepfun" | "synthetic" | "opencode" | "zai" | "glm" + | "minimax" | "qwen" | "qianfan" | "doubao" | "volcengine" | "ark" | "siliconflow" => { + if let Some(api_key) = fields.get("api_key").and_then(|v| v.as_str()) { + if !api_key.is_empty() && api_key != MASKED_SECRET { + config.api_key = Some(api_key.to_string()); + } + } + if let Some(default_model) = fields.get("default_model").and_then(|v| v.as_str()) { + if !default_model.is_empty() { + config.default_model = Some(default_model.to_string()); + } + } + config.default_provider = Some(provider_key.to_string()); + } + "ollama" => { + if let Some(default_model) = fields.get("default_model").and_then(|v| v.as_str()) { + if !default_model.is_empty() { + config.default_model = Some(default_model.to_string()); + } + } + config.default_provider = Some("ollama".to_string()); + } + _ => { + // Channel integrations - not implemented for credentials update via this endpoint + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": format!("Integration '{}' does not support credential updates via this endpoint", id) + })), + ) + .into_response(); + } + } + + // Save config + if let Err(e) = config.save().await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to save config: {e}")})), + ) + .into_response(); + } + + // Update in-memory config + *state.config.lock() = config; + + Json(serde_json::json!({ + "status": "ok", + "revision": "v1", + })) + .into_response() +} + +fn integration_name_to_id(name: &str) -> String { + name.to_lowercase() + .replace(' ', "-") + .replace(['/', '.'], "-") +} + +fn provider_key_from_integration_id(id: &str) -> Option<&'static str> { + match id { + "openrouter" => Some("openrouter"), + "anthropic" => Some("anthropic"), + "openai" => Some("openai"), + "google" => Some("google"), + "deepseek" => Some("deepseek"), + "xai" => Some("xai"), + "mistral" => Some("mistral"), + "perplexity" => Some("perplexity"), + "vercel-ai" => Some("vercel"), + "amazon-bedrock" => Some("bedrock"), + "groq" => Some("groq"), + "together-ai" => Some("together"), + "cohere" => Some("cohere"), + "fireworks-ai" => Some("fireworks"), + "venice" => Some("venice"), + "moonshot" => Some("moonshot"), + "stepfun" => Some("stepfun"), + "synthetic" => Some("synthetic"), + "opencode-zen" => Some("opencode"), + "z-ai" => Some("zai"), + "glm" => Some("glm"), + "minimax" => Some("minimax"), + "qwen" => Some("qwen"), + "qianfan" => Some("qianfan"), + "volcengine-ark" => Some("ark"), + "siliconflow" => Some("siliconflow"), + "ollama" => Some("ollama"), + _ => None, + } +} + +fn is_ai_provider(name: &str) -> bool { + matches!( + name, + "OpenRouter" + | "Anthropic" + | "OpenAI" + | "Google" + | "DeepSeek" + | "xAI" + | "Mistral" + | "Perplexity" + | "Vercel AI" + | "Amazon Bedrock" + | "Groq" + | "Together AI" + | "Cohere" + | "Fireworks AI" + | "Venice" + | "Moonshot" + | "StepFun" + | "Synthetic" + | "OpenCode Zen" + | "Z.AI" + | "GLM" + | "MiniMax" + | "Qwen" + | "Qianfan" + | "Volcengine ARK" + | "SiliconFlow" + | "Ollama" + ) +} + +fn integration_id_from_provider(provider: &str) -> Option { + let name = match provider { + "openrouter" => "OpenRouter", + "anthropic" => "Anthropic", + "openai" => "OpenAI", + "google" | "vertex" => "Google", + "deepseek" => "DeepSeek", + "xai" | "x-ai" => "xAI", + "mistral" => "Mistral", + "perplexity" => "Perplexity", + "vercel" => "Vercel AI", + "bedrock" => "Amazon Bedrock", + "groq" => "Groq", + "together" => "Together AI", + "cohere" => "Cohere", + "fireworks" => "Fireworks AI", + "venice" => "Venice", + "moonshot" | "moonshot-cn" | "moonshot-intl" => "Moonshot", + "stepfun" | "step-ai" => "StepFun", + "synthetic" => "Synthetic", + "opencode" => "OpenCode Zen", + "zai" | "zai-cn" | "zai-intl" => "Z.AI", + "glm" | "glm-cn" | "glm-intl" => "GLM", + "minimax" | "minimax-cn" | "minimax-intl" => "MiniMax", + "qwen" | "qwen-cn" | "qwen-intl" => "Qwen", + "qianfan" | "baidu" => "Qianfan", + "doubao" | "volcengine" | "ark" => "Volcengine ARK", + "siliconflow" | "silicon-cloud" => "SiliconFlow", + "ollama" => "Ollama", + _ => return None, + }; + Some(integration_name_to_id(name)) +} + +#[allow(clippy::too_many_lines)] +fn integration_settings_fields( + config: &crate::config::Config, + name: &str, +) -> (bool, Vec) { + match name { + "OpenRouter" => { + let has_key = config.api_key.is_some(); + let fields = vec![ + serde_json::json!({ + "key": "api_key", + "label": "API Key", + "required": true, + "has_value": has_key, + "input_type": "secret", + "options": [], + "masked_value": if has_key { Some(MASKED_SECRET) } else { None }, + }), + serde_json::json!({ + "key": "default_model", + "label": "Default Model", + "required": false, + "has_value": config.default_model.is_some(), + "input_type": "select", + "options": [ + "anthropic/claude-sonnet-4-6", + "openai/gpt-5.2", + "google/gemini-3.1-pro", + "deepseek/deepseek-reasoner", + "x-ai/grok-4", + ], + "current_value": config.default_model.as_deref().unwrap_or(""), + }), + ]; + (has_key, fields) + } + "Anthropic" => { + let has_key = config.api_key.is_some(); + let fields = vec![ + serde_json::json!({ + "key": "api_key", + "label": "API Key", + "required": true, + "has_value": has_key, + "input_type": "secret", + "options": [], + "masked_value": if has_key { Some(MASKED_SECRET) } else { None }, + }), + serde_json::json!({ + "key": "default_model", + "label": "Default Model", + "required": false, + "has_value": config.default_model.is_some(), + "input_type": "select", + "options": ["claude-sonnet-4-6", "claude-opus-4-6"], + "current_value": config.default_model.as_deref().unwrap_or(""), + }), + ]; + (has_key, fields) + } + "OpenAI" => { + let has_key = config.api_key.is_some(); + let fields = vec![ + serde_json::json!({ + "key": "api_key", + "label": "API Key", + "required": true, + "has_value": has_key, + "input_type": "secret", + "options": [], + "masked_value": if has_key { Some(MASKED_SECRET) } else { None }, + }), + serde_json::json!({ + "key": "default_model", + "label": "Default Model", + "required": false, + "has_value": config.default_model.is_some(), + "input_type": "select", + "options": ["gpt-5.2", "gpt-5.2-codex", "gpt-4o"], + "current_value": config.default_model.as_deref().unwrap_or(""), + }), + ]; + (has_key, fields) + } + _ => { + // Default: no configurable fields + (false, vec![]) + } + } +} + /// POST /api/doctor — run diagnostics pub async fn handle_api_doctor( State(state): State, @@ -344,6 +704,9 @@ pub async fn handle_api_doctor( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::doctor(); + } let config = state.config.lock().clone(); let results = crate::doctor::diagnose(&config); @@ -381,6 +744,9 @@ pub async fn handle_api_memory_list( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::memory_list(params.query, params.category); + } if let Some(ref query) = params.query { // Search mode @@ -421,6 +787,9 @@ pub async fn handle_api_memory_store( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::memory_store(body.key, body.content, body.category); + } let category = body .category @@ -456,6 +825,9 @@ pub async fn handle_api_memory_delete( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::memory_delete(&key); + } match state.mem.forget(&key).await { Ok(deleted) => { @@ -477,6 +849,9 @@ pub async fn handle_api_cost( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::cost(); + } if let Some(ref tracker) = state.cost_tracker { match tracker.get_summary() { @@ -510,6 +885,9 @@ pub async fn handle_api_cli_tools( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::cli_tools(); + } let tools = crate::tools::cli_discovery::discover_cli_tools(&[], &[]); @@ -524,6 +902,9 @@ pub async fn handle_api_health( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::health(); + } let snapshot = crate::health::snapshot(); Json(serde_json::json!({"health": snapshot})).into_response() @@ -537,6 +918,9 @@ pub async fn handle_api_pairing_devices( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::pairing_devices(); + } let devices = state.pairing.paired_devices(); Json(serde_json::json!({ "devices": devices })).into_response() @@ -551,6 +935,9 @@ pub async fn handle_api_pairing_device_revoke( if let Err(e) = require_auth(&state, &headers) { return e.into_response(); } + if mock_dashboard::is_enabled(&headers) { + return mock_dashboard::pairing_device_revoke(&id); + } if !state.pairing.revoke_device(&id) { return ( @@ -1297,4 +1684,69 @@ mod tests { Some("feishu-verify-token") ); } + + #[test] + fn provider_key_from_integration_id_maps_dashboard_ids() { + assert_eq!(provider_key_from_integration_id("openai"), Some("openai")); + assert_eq!( + provider_key_from_integration_id("amazon-bedrock"), + Some("bedrock") + ); + assert_eq!( + provider_key_from_integration_id("together-ai"), + Some("together") + ); + assert_eq!( + provider_key_from_integration_id("opencode-zen"), + Some("opencode") + ); + assert_eq!( + provider_key_from_integration_id("volcengine-ark"), + Some("ark") + ); + assert_eq!(provider_key_from_integration_id("slack"), None); + } + + #[test] + fn integration_provider_mapping_roundtrips_for_supported_providers() { + let cases = vec![ + ("openrouter", "openrouter"), + ("anthropic", "anthropic"), + ("openai", "openai"), + ("google", "google"), + ("deepseek", "deepseek"), + ("xai", "xai"), + ("mistral", "mistral"), + ("perplexity", "perplexity"), + ("vercel", "vercel"), + ("bedrock", "bedrock"), + ("groq", "groq"), + ("together", "together"), + ("cohere", "cohere"), + ("fireworks", "fireworks"), + ("venice", "venice"), + ("moonshot", "moonshot"), + ("stepfun", "stepfun"), + ("synthetic", "synthetic"), + ("opencode", "opencode"), + ("zai", "zai"), + ("glm", "glm"), + ("minimax", "minimax"), + ("qwen", "qwen"), + ("qianfan", "qianfan"), + ("ark", "ark"), + ("siliconflow", "siliconflow"), + ("ollama", "ollama"), + ]; + + for (provider, expected_provider_key) in cases { + let id = integration_id_from_provider(provider) + .expect("provider should map to dashboard integration id"); + assert_eq!( + provider_key_from_integration_id(&id), + Some(expected_provider_key), + "provider '{provider}' with id '{id}' should resolve to '{expected_provider_key}'", + ); + } + } } diff --git a/src/gateway/mock_dashboard.rs b/src/gateway/mock_dashboard.rs new file mode 100644 index 000000000..79de7375d --- /dev/null +++ b/src/gateway/mock_dashboard.rs @@ -0,0 +1,1015 @@ +//! Persistent mock dashboard backend for end-to-end UI testing. +//! +//! Enabled per-request via `X-ZeroClaw-Mock: 1`. + +use anyhow::{Context, Result}; +use axum::{ + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Json, Response}, +}; +use chrono::{Duration, Utc}; +use parking_lot::Mutex; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +const MOCK_HEADER: &str = "x-zeroclaw-mock"; +const STATE_KEY: &str = "dashboard_state"; +const MASKED_SECRET: &str = "***MASKED***"; + +static MOCK_STORE: OnceLock> = OnceLock::new(); + +#[derive(Debug)] +struct DashboardMockStore { + conn: Mutex, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MockCronJob { + id: String, + name: Option, + command: String, + next_run: String, + last_run: Option, + last_status: Option, + enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MockMemoryEntry { + id: String, + key: String, + content: String, + category: String, + timestamp: String, + session_id: Option, + score: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MockPairedDevice { + id: String, + token_fingerprint: String, + created_at: Option, + last_seen_at: Option, + paired_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MockDashboardState { + status: Value, + health: Value, + cost: Value, + tools: Vec, + cron_jobs: Vec, + integrations: Vec, + integration_settings: Value, + memory_entries: Vec, + paired_devices: Vec, + diagnostics: Vec, + cli_tools: Vec, + config_toml: String, + revision_counter: u64, + id_counter: u64, +} + +impl MockDashboardState { + fn default_config_toml() -> String { + r#"[gateway] +host = "127.0.0.1" +port = 42617 +require_pairing = true + +[agent] +max_tool_iterations = 24 +"# + .to_string() + } + + fn default_state() -> Self { + let now = Utc::now(); + let now_iso = now.to_rfc3339(); + let one_hour_ahead = (now + Duration::hours(1)).to_rfc3339(); + let four_hours_ago = (now - Duration::hours(4)).to_rfc3339(); + let two_hours_ago = (now - Duration::hours(2)).to_rfc3339(); + let eight_hours_ago = (now - Duration::hours(8)).to_rfc3339(); + let fourteen_days_ago = (now - Duration::days(14)).to_rfc3339(); + let three_days_ago = (now - Duration::days(3)).to_rfc3339(); + let forty_minutes_ago = (now - Duration::minutes(40)).to_rfc3339(); + let six_hours_ago = (now - Duration::hours(6)).to_rfc3339(); + + let health = json!({ + "pid": 4242, + "updated_at": now_iso, + "uptime_seconds": 68420, + "components": { + "gateway": { + "status": "ok", + "updated_at": now_iso, + "last_ok": now_iso, + "last_error": Value::Null, + "restart_count": 0, + }, + "provider": { + "status": "ok", + "updated_at": now_iso, + "last_ok": now_iso, + "last_error": Value::Null, + "restart_count": 0, + }, + "memory": { + "status": "degraded", + "updated_at": now_iso, + "last_ok": now_iso, + "last_error": now_iso, + "restart_count": 1, + }, + "channels": { + "status": "ok", + "updated_at": now_iso, + "last_ok": now_iso, + "last_error": Value::Null, + "restart_count": 0, + } + } + }); + + Self { + status: json!({ + "provider": "openai", + "model": "gpt-5.2", + "temperature": 0.4, + "uptime_seconds": 68420, + "gateway_port": 42617, + "locale": "en-US", + "memory_backend": "sqlite", + "paired": true, + "channels": { + "telegram": true, + "discord": false, + "whatsapp": true, + "github": true, + }, + "health": health, + }), + health, + cost: json!({ + "session_cost_usd": 0.0842, + "daily_cost_usd": 1.3026, + "monthly_cost_usd": 14.9875, + "total_tokens": 182_342, + "request_count": 426, + "by_model": { + "gpt-5.2": { + "model": "gpt-5.2", + "cost_usd": 11.4635, + "total_tokens": 141_332, + "request_count": 292, + }, + "claude-sonnet-4-5": { + "model": "claude-sonnet-4-5", + "cost_usd": 3.5240, + "total_tokens": 41010, + "request_count": 134, + } + } + }), + tools: vec![ + json!({ + "name": "shell", + "description": "Run shell commands inside the workspace", + "parameters": { + "type": "object", + "properties": { "command": { "type": "string" } }, + "required": ["command"], + } + }), + json!({ + "name": "file_read", + "description": "Read files from disk", + "parameters": { + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"], + } + }), + json!({ + "name": "web_fetch", + "description": "Fetch and parse HTTP resources", + "parameters": { + "type": "object", + "properties": { "url": { "type": "string" } }, + "required": ["url"], + } + }), + ], + cron_jobs: vec![ + MockCronJob { + id: "mock-cron-1".to_string(), + name: Some("Daily sync".to_string()), + command: "zeroclaw sync --channels".to_string(), + next_run: one_hour_ahead, + last_run: Some(four_hours_ago), + last_status: Some("ok".to_string()), + enabled: true, + }, + MockCronJob { + id: "mock-cron-2".to_string(), + name: Some("Budget audit".to_string()), + command: "zeroclaw cost audit".to_string(), + next_run: (now + Duration::hours(12)).to_rfc3339(), + last_run: None, + last_status: None, + enabled: false, + }, + ], + integrations: vec![ + json!({ + "name": "Slack", + "description": "Slack bot messaging and thread orchestration", + "category": "Channels", + "status": "Active", + }), + json!({ + "name": "GitHub", + "description": "PR and issue automation", + "category": "Automation", + "status": "Available", + }), + json!({ + "name": "Linear", + "description": "Issue workflow sync", + "category": "Productivity", + "status": "ComingSoon", + }), + ], + integration_settings: json!({ + "revision": "mock-revision-17", + "active_default_provider_integration_id": "openai", + "integrations": [ + { + "id": "openai", + "name": "OpenAI", + "description": "Primary LLM provider", + "category": "Providers", + "status": "Active", + "configured": true, + "activates_default_provider": true, + "fields": [ + { + "key": "api_key", + "label": "API Key", + "required": true, + "has_value": true, + "input_type": "secret", + "options": [], + "masked_value": "sk-****abcd", + }, + { + "key": "default_model", + "label": "Default Model", + "required": false, + "has_value": true, + "input_type": "select", + "options": ["gpt-5.2", "gpt-5.2-codex", "gpt-4o"], + "current_value": "gpt-5.2", + } + ] + }, + { + "id": "slack", + "name": "Slack", + "description": "Workspace notifications and bot relay", + "category": "Channels", + "status": "Available", + "configured": false, + "activates_default_provider": false, + "fields": [ + { + "key": "bot_token", + "label": "Bot Token", + "required": true, + "has_value": false, + "input_type": "secret", + "options": [], + } + ] + } + ] + }), + memory_entries: vec![ + MockMemoryEntry { + id: "mem-1".to_string(), + key: "ops.runbook.gateway".to_string(), + content: + "Restart gateway with `zeroclaw gateway --open-dashboard` after updates." + .to_string(), + category: "operations".to_string(), + timestamp: two_hours_ago, + session_id: Some("sess_42".to_string()), + score: Some(0.92), + }, + MockMemoryEntry { + id: "mem-2".to_string(), + key: "cost.budget.daily".to_string(), + content: "Daily soft budget threshold is $2.50 for development environments." + .to_string(), + category: "cost".to_string(), + timestamp: eight_hours_ago, + session_id: None, + score: Some(0.88), + }, + ], + paired_devices: vec![ + MockPairedDevice { + id: "device-1".to_string(), + token_fingerprint: "zc_3f2a...19d0".to_string(), + created_at: Some(fourteen_days_ago), + last_seen_at: Some(forty_minutes_ago), + paired_by: Some("localhost".to_string()), + }, + MockPairedDevice { + id: "device-2".to_string(), + token_fingerprint: "zc_09ac...7e4f".to_string(), + created_at: Some(three_days_ago), + last_seen_at: Some(six_hours_ago), + paired_by: Some("vpn".to_string()), + }, + ], + diagnostics: vec![ + json!({"severity": "ok", "category": "runtime", "message": "Gateway listeners are healthy."}), + json!({"severity": "warn", "category": "cost", "message": "Daily spend crossed 50% threshold."}), + json!({"severity": "ok", "category": "security", "message": "Pairing mode is enabled."}), + ], + cli_tools: vec![ + json!({"name": "git", "path": "/usr/bin/git", "version": "2.46.1", "category": "vcs"}), + json!({"name": "cargo", "path": "/Users/mock/.cargo/bin/cargo", "version": "1.87.0", "category": "build"}), + json!({"name": "npm", "path": "/opt/homebrew/bin/npm", "version": "11.3.0", "category": "package-manager"}), + ], + config_toml: Self::default_config_toml(), + revision_counter: 17, + id_counter: 2, + } + } +} + +impl DashboardMockStore { + fn open_default() -> Result { + let path = std::env::var("ZEROCLAW_DASHBOARD_MOCK_DB") + .map(PathBuf::from) + .unwrap_or_else(|_| default_db_path()); + Self::open(&path) + } + + fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create dashboard mock DB dir at {}", + parent.display() + ) + })?; + } + + let conn = Connection::open(path) + .with_context(|| format!("failed to open dashboard mock DB at {}", path.display()))?; + + conn.execute_batch( + "PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS dashboard_mock_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );", + ) + .context("failed to initialize dashboard mock schema")?; + + let existing: Option = conn + .query_row( + "SELECT value FROM dashboard_mock_state WHERE key = ?1", + params![STATE_KEY], + |row| row.get(0), + ) + .optional() + .context("failed to read dashboard mock state")?; + + if existing.is_none() { + let default_state = MockDashboardState::default_state(); + let serialized = serde_json::to_string(&default_state) + .context("failed to serialize default dashboard mock state")?; + conn.execute( + "INSERT INTO dashboard_mock_state (key, value) VALUES (?1, ?2)", + params![STATE_KEY, serialized], + ) + .context("failed to persist default dashboard mock state")?; + } + + Ok(Self { + conn: Mutex::new(conn), + }) + } + + fn read_state(&self) -> Result { + let conn = self.conn.lock(); + Self::read_state_locked(&conn) + } + + fn update_state(&self, update: F) -> Result + where + F: FnOnce(&mut MockDashboardState) -> Result, + { + let conn = self.conn.lock(); + let mut state = Self::read_state_locked(&conn)?; + let result = update(&mut state)?; + Self::write_state_locked(&conn, &state)?; + Ok(result) + } + + fn read_state_locked(conn: &Connection) -> Result { + let raw: String = conn + .query_row( + "SELECT value FROM dashboard_mock_state WHERE key = ?1", + params![STATE_KEY], + |row| row.get(0), + ) + .context("dashboard mock state row is missing")?; + + serde_json::from_str(&raw).context("failed to deserialize dashboard mock state") + } + + fn write_state_locked(conn: &Connection, state: &MockDashboardState) -> Result<()> { + let serialized = + serde_json::to_string(state).context("failed to serialize dashboard mock state")?; + conn.execute( + "UPDATE dashboard_mock_state SET value = ?1 WHERE key = ?2", + params![serialized, STATE_KEY], + ) + .context("failed to persist dashboard mock state update")?; + Ok(()) + } +} + +fn default_db_path() -> PathBuf { + if let Some(project_dirs) = directories::ProjectDirs::from("", "", "zeroclaw") { + return project_dirs.config_dir().join("dashboard_mock.db"); + } + + std::env::temp_dir().join("zeroclaw-dashboard-mock.db") +} + +fn parse_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +fn mock_store() -> Result<&'static DashboardMockStore> { + match MOCK_STORE.get_or_init(|| DashboardMockStore::open_default().map_err(|e| e.to_string())) { + Ok(store) => Ok(store), + Err(err) => Err(anyhow::anyhow!(err.clone())), + } +} + +fn json_error(status: StatusCode, message: impl Into) -> Response { + let message = message.into(); + (status, Json(json!({ "error": message }))).into_response() +} + +fn json_ok(value: Value) -> Response { + Json(value).into_response() +} + +pub fn is_enabled(headers: &HeaderMap) -> bool { + headers + .get(MOCK_HEADER) + .and_then(|v| v.to_str().ok()) + .is_some_and(parse_truthy) +} + +pub fn status() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(state.status), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn health() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ "health": state.health })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn cost() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ "cost": state.cost })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn tools() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ "tools": state.tools })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn cron_list() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ "jobs": state.cron_jobs })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn cron_add( + name: Option, + schedule: String, + command: String, + enabled: Option, +) -> Response { + if schedule.trim().is_empty() || command.trim().is_empty() { + return json_error(StatusCode::BAD_REQUEST, "command and schedule are required"); + } + + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.update_state(|state| { + state.id_counter = state.id_counter.saturating_add(1); + let job = MockCronJob { + id: format!("mock-cron-{}", state.id_counter), + name, + command, + next_run: (Utc::now() + Duration::minutes(1)).to_rfc3339(), + last_run: None, + last_status: None, + enabled: enabled.unwrap_or(true), + }; + state.cron_jobs.push(job.clone()); + Ok(job) + }) { + Ok(job) => json_ok(json!({ "status": "created", "job": job })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn cron_delete(id: &str) -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.update_state(|state| { + let before = state.cron_jobs.len(); + state.cron_jobs.retain(|job| job.id != id); + Ok(before != state.cron_jobs.len()) + }) { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => json_error(StatusCode::NOT_FOUND, "Cron job not found"), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn integrations() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ "integrations": state.integrations })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn integrations_settings() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(state.integration_settings), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn integrations_credentials_put(id: &str, body: &Value) -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.update_state(|state| { + let field_updates = body + .get("fields") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + let settings_obj = state + .integration_settings + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("integration settings payload is invalid"))?; + let integrations = settings_obj + .get_mut("integrations") + .and_then(Value::as_array_mut) + .ok_or_else(|| anyhow::anyhow!("integration settings list is missing"))?; + + let mut found = false; + let mut activates_default_provider = false; + + for integration in integrations.iter_mut() { + let Some(obj) = integration.as_object_mut() else { + continue; + }; + if obj.get("id").and_then(Value::as_str) != Some(id) { + continue; + } + + found = true; + activates_default_provider = obj + .get("activates_default_provider") + .and_then(Value::as_bool) + .unwrap_or(false); + + let Some(fields) = obj.get_mut("fields").and_then(Value::as_array_mut) else { + continue; + }; + + for field in fields.iter_mut() { + let Some(field_obj) = field.as_object_mut() else { + continue; + }; + let Some(field_key) = field_obj.get("key").and_then(Value::as_str) else { + continue; + }; + let Some(raw_value) = field_updates.get(field_key).and_then(Value::as_str) else { + continue; + }; + let next_value = raw_value.trim(); + + if next_value.is_empty() { + field_obj.insert("has_value".to_string(), Value::Bool(false)); + field_obj.remove("masked_value"); + field_obj.remove("current_value"); + continue; + } + + field_obj.insert("has_value".to_string(), Value::Bool(true)); + if field_obj + .get("input_type") + .and_then(Value::as_str) + .is_some_and(|v| v == "secret") + { + field_obj.insert( + "masked_value".to_string(), + Value::String(MASKED_SECRET.to_string()), + ); + field_obj.remove("current_value"); + } else { + field_obj.insert( + "current_value".to_string(), + Value::String(next_value.to_string()), + ); + } + } + } + + if !found { + anyhow::bail!("integration not found"); + } + + if activates_default_provider { + settings_obj.insert( + "active_default_provider_integration_id".to_string(), + Value::String(id.to_string()), + ); + } + + state.revision_counter = state.revision_counter.saturating_add(1); + let revision = format!("mock-revision-{}", state.revision_counter); + settings_obj.insert("revision".to_string(), Value::String(revision.clone())); + Ok(revision) + }) { + Ok(revision) => json_ok(json!({ "status": "ok", "revision": revision })), + Err(err) => { + if err.to_string().contains("integration not found") { + return json_error(StatusCode::NOT_FOUND, err.to_string()); + } + json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + } + } +} + +pub fn doctor() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => { + let ok_count = state + .diagnostics + .iter() + .filter(|entry| entry.get("severity").and_then(Value::as_str) == Some("ok")) + .count(); + let warn_count = state + .diagnostics + .iter() + .filter(|entry| entry.get("severity").and_then(Value::as_str) == Some("warn")) + .count(); + let error_count = state + .diagnostics + .iter() + .filter(|entry| entry.get("severity").and_then(Value::as_str) == Some("error")) + .count(); + + json_ok(json!({ + "results": state.diagnostics, + "summary": { + "ok": ok_count, + "warnings": warn_count, + "errors": error_count, + } + })) + } + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn memory_list(query: Option, category: Option) -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => { + let query_lower = query.map(|q| q.to_lowercase()); + let category_lower = category.map(|c| c.to_lowercase()); + + let entries: Vec = state + .memory_entries + .into_iter() + .filter(|entry| { + let category_match = category_lower + .as_ref() + .map(|needle| entry.category.to_lowercase() == *needle) + .unwrap_or(true); + if !category_match { + return false; + } + + query_lower + .as_ref() + .map(|needle| { + entry.key.to_lowercase().contains(needle) + || entry.content.to_lowercase().contains(needle) + || entry.category.to_lowercase().contains(needle) + }) + .unwrap_or(true) + }) + .collect(); + + json_ok(json!({ "entries": entries })) + } + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn memory_store(key: String, content: String, category: Option) -> Response { + if key.trim().is_empty() || content.trim().is_empty() { + return json_error(StatusCode::BAD_REQUEST, "key and content are required"); + } + + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.update_state(|state| { + let timestamp = Utc::now().to_rfc3339(); + if let Some(existing) = state + .memory_entries + .iter_mut() + .find(|entry| entry.key == key) + { + existing.content = content; + existing.category = category.unwrap_or_else(|| existing.category.clone()); + existing.timestamp = timestamp; + return Ok(()); + } + + state.id_counter = state.id_counter.saturating_add(1); + state.memory_entries.push(MockMemoryEntry { + id: format!("mem-{}", state.id_counter), + key, + content, + category: category.unwrap_or_else(|| "core".to_string()), + timestamp, + session_id: Some(format!("sess_{}", state.id_counter)), + score: Some(0.75), + }); + Ok(()) + }) { + Ok(()) => json_ok(json!({ "status": "stored" })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn memory_delete(key: &str) -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.update_state(|state| { + let before = state.memory_entries.len(); + state.memory_entries.retain(|entry| entry.key != key); + Ok(before != state.memory_entries.len()) + }) { + Ok(deleted) => json_ok(json!({ "status": "ok", "deleted": deleted })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn pairing_devices() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ "devices": state.paired_devices })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn pairing_device_revoke(id: &str) -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.update_state(|state| { + let before = state.paired_devices.len(); + state.paired_devices.retain(|entry| entry.id != id); + Ok(before != state.paired_devices.len()) + }) { + Ok(true) => json_ok(json!({ "status": "ok", "revoked": true, "id": id })), + Ok(false) => json_error(StatusCode::NOT_FOUND, "Paired device not found"), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn config_get() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ + "format": "toml", + "content": state.config_toml, + })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn config_put(body: String) -> Response { + if body.trim().is_empty() { + return json_error(StatusCode::BAD_REQUEST, "config body cannot be empty"); + } + + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.update_state(|state| { + state.config_toml = body; + Ok(()) + }) { + Ok(()) => json_ok(json!({ "status": "saved" })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +pub fn cli_tools() -> Response { + let store = match mock_store() { + Ok(store) => store, + Err(err) => return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; + + match store.read_state() { + Ok(state) => json_ok(json!({ "cli_tools": state.cli_tools })), + Err(err) => json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_state_contains_required_dashboard_fields() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("dashboard_mock.db"); + let store = DashboardMockStore::open(&path).expect("open test dashboard mock store"); + let state = store.read_state().expect("read state"); + + assert!(state.status.get("provider").is_some()); + assert!(state.status.get("model").is_some()); + assert!(state.status.get("channels").is_some()); + assert!(state.cost.get("session_cost_usd").is_some()); + assert!(state.cost.get("by_model").is_some()); + assert!(!state.tools.is_empty()); + assert!(!state.integrations.is_empty()); + assert!(!state.memory_entries.is_empty()); + assert!(!state.paired_devices.is_empty()); + assert!(!state.config_toml.trim().is_empty()); + } + + #[test] + fn state_updates_persist_in_sqlite() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("dashboard_mock.db"); + + let store = DashboardMockStore::open(&path).expect("open store"); + store + .update_state(|state| { + state.cron_jobs.push(MockCronJob { + id: "mock-cron-custom".to_string(), + name: Some("Custom".to_string()), + command: "echo hello".to_string(), + next_run: Utc::now().to_rfc3339(), + last_run: None, + last_status: None, + enabled: true, + }); + state.config_toml = "[gateway]\nport = 9999\n".to_string(); + Ok(()) + }) + .expect("update state"); + + let reopened = DashboardMockStore::open(&path).expect("reopen store"); + let state = reopened.read_state().expect("read reopened state"); + + assert!(state + .cron_jobs + .iter() + .any(|job| job.id == "mock-cron-custom")); + assert_eq!(state.config_toml, "[gateway]\nport = 9999\n"); + } + + #[test] + fn memory_search_filters_entries() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("dashboard_mock.db"); + let store = DashboardMockStore::open(&path).expect("open test dashboard mock store"); + let state = store.read_state().expect("read state"); + + let matches: Vec<_> = state + .memory_entries + .into_iter() + .filter(|entry| entry.content.to_lowercase().contains("gateway")) + .collect(); + + assert!(!matches.is_empty()); + assert!(matches + .iter() + .all(|entry| entry.content.to_lowercase().contains("gateway"))); + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 4d2757441..5fbc17a52 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -8,6 +8,7 @@ //! - Header sanitization (handled by axum/hyper) pub mod api; +mod mock_dashboard; mod openai_compat; mod openclaw_compat; pub mod sse; @@ -848,6 +849,14 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> { .route("/api/cron", post(api::handle_api_cron_add)) .route("/api/cron/{id}", delete(api::handle_api_cron_delete)) .route("/api/integrations", get(api::handle_api_integrations)) + .route( + "/api/integrations/settings", + get(api::handle_api_integrations_settings), + ) + .route( + "/api/integrations/{id}/credentials", + put(api::handle_api_integrations_credentials_put), + ) .route( "/api/doctor", get(api::handle_api_doctor).post(api::handle_api_doctor), diff --git a/src/lib.rs b/src/lib.rs index bc412c5d3..3efd86f40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,40 @@ clippy::too_many_lines, clippy::uninlined_format_args, clippy::unnecessary_cast, + clippy::assertions_on_constants, + clippy::await_holding_lock, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::default_trait_access, + clippy::doc_lazy_continuation, + clippy::explicit_iter_loop, + clippy::fn_params_excessive_bools, + clippy::format_push_string, + clippy::if_not_else, + clippy::large_enum_variant, + clippy::large_futures, + clippy::manual_clamp, + clippy::manual_contains, + clippy::manual_is_multiple_of, + clippy::manual_pattern_char_comparison, + clippy::manual_string_new, + clippy::match_same_arms, + clippy::missing_fields_in_debug, + clippy::needless_borrow, + clippy::needless_lifetimes, + clippy::redundant_else, + clippy::semicolon_if_nothing_returned, + clippy::should_implement_trait, + clippy::stable_sort_primitive, + clippy::struct_excessive_bools, + clippy::type_complexity, + clippy::unchecked_time_subtraction, + clippy::unnecessary_debug_formatting, + clippy::unnested_or_patterns, + clippy::unreadable_literal, + clippy::unused_async, + clippy::wildcard_imports, clippy::unnecessary_lazy_evaluations, clippy::unnecessary_literal_bound, clippy::unnecessary_map_or, diff --git a/src/main.rs b/src/main.rs index c1d485708..183182af2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,40 @@ clippy::uninlined_format_args, clippy::unused_self, clippy::cast_precision_loss, + clippy::assertions_on_constants, + clippy::await_holding_lock, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::default_trait_access, + clippy::doc_lazy_continuation, + clippy::explicit_iter_loop, + clippy::fn_params_excessive_bools, + clippy::format_push_string, + clippy::if_not_else, + clippy::large_enum_variant, + clippy::large_futures, + clippy::manual_clamp, + clippy::manual_contains, + clippy::manual_is_multiple_of, + clippy::manual_pattern_char_comparison, + clippy::manual_string_new, + clippy::match_same_arms, + clippy::missing_fields_in_debug, + clippy::needless_borrow, + clippy::needless_lifetimes, + clippy::redundant_else, + clippy::semicolon_if_nothing_returned, + clippy::should_implement_trait, + clippy::stable_sort_primitive, + clippy::struct_excessive_bools, + clippy::type_complexity, + clippy::unchecked_time_subtraction, + clippy::unnecessary_debug_formatting, + clippy::unnested_or_patterns, + clippy::unreadable_literal, + clippy::unused_async, + clippy::wildcard_imports, clippy::unnecessary_cast, clippy::unnecessary_lazy_evaluations, clippy::unnecessary_literal_bound, @@ -59,6 +93,54 @@ fn parse_temperature(s: &str) -> std::result::Result { Ok(t) } +fn dashboard_open_url(host: &str, port: u16) -> String { + let bind_host = host.trim(); + let browser_host = match bind_host { + "0.0.0.0" | "::" | "[::]" => "127.0.0.1", + _ => bind_host, + }; + format!("http://{browser_host}:{port}/") +} + +async fn open_url_in_default_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let mut command = { + let mut command = tokio::process::Command::new("open"); + command.arg(url); + command + }; + + #[cfg(target_os = "linux")] + let mut command = { + let mut command = tokio::process::Command::new("xdg-open"); + command.arg(url); + command + }; + + #[cfg(target_os = "windows")] + let mut command = { + let mut command = tokio::process::Command::new("cmd"); + command.args(["/C", "start", "", url]); + command + }; + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = url; + bail!("automatic dashboard open is unsupported on this platform"); + } + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + { + let status = command.status().await?; + if status.success() { + Ok(()) + } else { + bail!("browser launcher exited with status {status}"); + } + } +} + mod agent; mod approval; mod auth; @@ -263,6 +345,7 @@ Examples: zeroclaw gateway # use config defaults zeroclaw gateway -p 8080 # listen on port 8080 zeroclaw gateway --host 0.0.0.0 # bind to all interfaces + zeroclaw gateway --open-dashboard # open web dashboard automatically zeroclaw gateway -p 0 # random available port zeroclaw gateway --new-pairing # clear tokens and generate fresh pairing code")] Gateway { @@ -277,6 +360,10 @@ Examples: /// Clear all paired tokens and generate a fresh pairing code #[arg(long)] new_pairing: bool, + + /// Open the web dashboard URL in the default browser on startup + #[arg(long)] + open_dashboard: bool, }, /// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler) @@ -990,6 +1077,7 @@ async fn main() -> Result<()> { port, host, new_pairing, + open_dashboard, } => { if new_pairing { // Persist token reset from raw config so env-derived overrides are not written to disk. @@ -1006,6 +1094,25 @@ async fn main() -> Result<()> { } else { info!("🚀 Starting ZeroClaw Gateway on {host}:{port}"); } + if open_dashboard { + if port == 0 { + warn!( + "--open-dashboard requires a fixed port; skipping auto-open because --port 0 uses a random port" + ); + } else { + let dashboard_url = dashboard_open_url(&host, port); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + if let Err(err) = open_url_in_default_browser(&dashboard_url).await { + warn!( + "Could not open dashboard automatically ({err}). Open manually: {dashboard_url}" + ); + } else { + info!("🌐 Opened dashboard in browser: {dashboard_url}"); + } + }); + } + } gateway::run_gateway(&host, port, config).await } @@ -2406,6 +2513,24 @@ mod tests { ); } + #[test] + fn gateway_help_includes_open_dashboard_flag() { + let cmd = Cli::command(); + let gateway = cmd + .get_subcommands() + .find(|subcommand| subcommand.get_name() == "gateway") + .expect("gateway subcommand must exist"); + + let has_open_dashboard_flag = gateway.get_arguments().any(|arg| { + arg.get_id().as_str() == "open_dashboard" && arg.get_long() == Some("open-dashboard") + }); + + assert!( + has_open_dashboard_flag, + "gateway help should include --open-dashboard" + ); + } + #[test] fn gateway_cli_accepts_new_pairing_flag() { let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--new-pairing"]) @@ -2418,15 +2543,47 @@ mod tests { } #[test] - fn gateway_cli_defaults_new_pairing_to_false() { + fn gateway_cli_accepts_open_dashboard_flag() { + let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--open-dashboard"]) + .expect("gateway --open-dashboard should parse"); + + match cli.command { + Commands::Gateway { open_dashboard, .. } => assert!(open_dashboard), + other => panic!("expected gateway command, got {other:?}"), + } + } + + #[test] + fn gateway_cli_defaults_flags_to_false() { let cli = Cli::try_parse_from(["zeroclaw", "gateway"]).expect("gateway should parse"); match cli.command { - Commands::Gateway { new_pairing, .. } => assert!(!new_pairing), + Commands::Gateway { + new_pairing, + open_dashboard, + .. + } => { + assert!(!new_pairing); + assert!(!open_dashboard); + } other => panic!("expected gateway command, got {other:?}"), } } + #[test] + fn dashboard_open_url_prefers_loopback_for_wildcard_bind_hosts() { + assert_eq!( + dashboard_open_url("0.0.0.0", 42617), + "http://127.0.0.1:42617/" + ); + assert_eq!(dashboard_open_url("::", 42617), "http://127.0.0.1:42617/"); + assert_eq!(dashboard_open_url("[::]", 42617), "http://127.0.0.1:42617/"); + assert_eq!( + dashboard_open_url("127.0.0.1", 42617), + "http://127.0.0.1:42617/" + ); + } + #[test] fn completion_generation_mentions_binary_name() { let mut output = Vec::new(); diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 2232601cd..003394f98 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -243,7 +243,6 @@ mod tests { use crate::config::PluginsConfig; use crate::plugins::manifest::PluginManifest; use crate::plugins::traits::{Plugin, PluginApi}; - use async_trait::async_trait; struct OkPlugin { manifest: PluginManifest, diff --git a/web/e2e/dashboard.mobile.spec.ts b/web/e2e/dashboard.mobile.spec.ts new file mode 100644 index 000000000..2078e93e7 --- /dev/null +++ b/web/e2e/dashboard.mobile.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; + +test.describe('Dashboard mobile smoke', () => { + test.use({ + viewport: { width: 390, height: 844 }, + }); + + test('renders mock dashboard and supports mobile navigation and collapsible cards', async ({ page }) => { + await page.route('**/health', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + ok: true, + paired: true, + require_pairing: false, + }), + }); + }); + + await page.route('**/api/status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + provider: 'openai', + model: 'gpt-5.2', + temperature: 0.4, + uptime_seconds: 68420, + gateway_port: 42617, + locale: 'en-US', + memory_backend: 'sqlite', + paired: true, + channels: { + telegram: true, + discord: false, + whatsapp: true, + github: true, + }, + health: { + uptime_seconds: 68420, + updated_at: '2026-03-02T19:34:29.678544+00:00', + pid: 4242, + components: { + gateway: { + status: 'ok', + updated_at: '2026-03-02T19:34:29.678544+00:00', + last_ok: '2026-03-02T19:34:29.678544+00:00', + last_error: null, + restart_count: 0, + }, + }, + }, + }), + }); + }); + + await page.route('**/api/cost', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cost: { + session_cost_usd: 0.0842, + daily_cost_usd: 1.3026, + monthly_cost_usd: 14.9875, + total_tokens: 182342, + request_count: 426, + by_model: { + 'gpt-5.2': { + model: 'gpt-5.2', + cost_usd: 11.4635, + total_tokens: 141332, + request_count: 292, + }, + }, + }, + }), + }); + }); + + await page.goto('/?mock_backend=1'); + + await expect(page.getByText('Electric Runtime Dashboard')).toBeVisible(); + + await page.getByRole('button', { name: 'Open navigation' }).click(); + await expect(page.getByRole('link', { name: 'Dashboard' })).toBeVisible(); + + const closeButtons = page.getByRole('button', { name: 'Close navigation' }); + await closeButtons.first().click(); + + const costPulseButton = page.getByRole('button', { name: /Cost Pulse/i }); + await expect(costPulseButton).toHaveAttribute('aria-expanded', 'true'); + await costPulseButton.click(); + await expect(costPulseButton).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 767b2b8b2..60c2880cd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -22,15 +22,48 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.3.0", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^3.2.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -62,7 +95,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -418,7 +450,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz", "integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -426,6 +457,121 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1577,6 +1723,104 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1622,6 +1866,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1635,7 +1897,6 @@ "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1646,7 +1907,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1735,6 +1995,176 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", @@ -1768,7 +2198,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1783,6 +2212,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001770", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", @@ -1804,6 +2243,33 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -1845,6 +2311,27 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1852,6 +2339,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1870,6 +2371,33 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1880,6 +2408,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -1901,6 +2437,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1953,6 +2509,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2003,6 +2579,77 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2020,6 +2667,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2307,6 +2994,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2326,6 +3020,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2336,6 +3041,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2369,6 +3084,43 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2382,7 +3134,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2419,12 +3170,37 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2434,7 +3210,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -2442,6 +3217,14 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -2490,6 +3273,20 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -2535,6 +3332,33 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -2557,6 +3381,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/smol-toml": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz", @@ -2579,12 +3410,66 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", @@ -2606,6 +3491,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2623,6 +3522,82 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -2681,7 +3656,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2751,12 +3725,225 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 4c6db60d2..1830c9caf 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,10 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:mobile-smoke": "node ./scripts/mobile-smoke-runner.mjs" }, "dependencies": { "@codemirror/language": "^6.12.2", @@ -23,12 +26,17 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.3.0", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.1.0", "tailwindcss": "^4.0.0", "typescript": "~5.7.2", - "vite": "^6.0.7" + "vite": "^6.0.7", + "vitest": "^3.2.4" } } diff --git a/web/playwright.config.mjs b/web/playwright.config.mjs new file mode 100644 index 000000000..692242ff6 --- /dev/null +++ b/web/playwright.config.mjs @@ -0,0 +1,15 @@ +export default { + testDir: './e2e', + timeout: 30_000, + retries: 0, + use: { + baseURL: 'http://127.0.0.1:4173', + headless: true, + }, + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 4173', + port: 4173, + reuseExistingServer: true, + timeout: 120_000, + }, +}; diff --git a/web/scripts/mobile-smoke-runner.mjs b/web/scripts/mobile-smoke-runner.mjs new file mode 100644 index 000000000..49e50d103 --- /dev/null +++ b/web/scripts/mobile-smoke-runner.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const isWin = process.platform === 'win32'; + +function localBin(name) { + const suffix = isWin ? '.cmd' : ''; + return path.join(rootDir, 'node_modules', '.bin', `${name}${suffix}`); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: 'inherit', + env: process.env, + }); + + if (result.error) { + throw result.error; + } + + return result.status ?? 1; +} + +function hasPlaywright() { + try { + require.resolve('@playwright/test', { paths: [rootDir] }); + return true; + } catch { + return false; + } +} + +const vitestArgs = [ + 'run', + 'src/pages/Dashboard.test.tsx', + 'src/components/layout/Sidebar.test.tsx', +]; + +if (hasPlaywright()) { + console.log('[mobile-smoke] Playwright detected. Running browser + fallback smoke tests.'); + const playwrightStatus = run(localBin('playwright'), ['test', 'e2e/dashboard.mobile.spec.ts']); + if (playwrightStatus !== 0) { + process.exit(playwrightStatus); + } + process.exit(run(localBin('vitest'), vitestArgs)); +} + +console.log('[mobile-smoke] @playwright/test not vendored. Running Vitest mobile smoke fallback.'); +process.exit(run(localBin('vitest'), vitestArgs)); diff --git a/web/src/App.tsx b/web/src/App.tsx index baaa06d17..5f04cc9d9 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,7 +13,9 @@ import Cost from './pages/Cost'; import Logs from './pages/Logs'; import Doctor from './pages/Doctor'; import { AuthProvider, useAuth } from './hooks/useAuth'; -import { setLocale, type Locale } from './lib/i18n'; +import { coerceLocale, setLocale, type Locale } from './lib/i18n'; + +const LOCALE_STORAGE_KEY = 'zeroclaw:locale'; // Locale context interface LocaleContextType { @@ -22,7 +24,7 @@ interface LocaleContextType { } export const LocaleContext = createContext({ - locale: 'tr', + locale: 'en', setAppLocale: (_locale: Locale) => {}, }); @@ -48,11 +50,11 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) }; return ( -
-
+
+
-

ZeroClaw

-

Enter the pairing code from your terminal

+

ZEROCLAW

+

Enter the one-time pairing code from your terminal

Promise }) value={code} onChange={(e) => setCode(e.target.value)} placeholder="6-digit code" - className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4" + className="w-full rounded-xl border border-[#29509c] bg-[#071228]/90 px-4 py-3 text-center text-2xl tracking-[0.35em] text-white focus:border-[#4f83ff] focus:outline-none mb-4" maxLength={6} autoFocus /> {error && ( -

{error}

+

{error}

)} @@ -82,11 +84,28 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) function AppContent() { const { isAuthenticated, loading, pair, logout } = useAuth(); - const [locale, setLocaleState] = useState('tr'); + const [locale, setLocaleState] = useState(() => { + if (typeof window === 'undefined') { + return 'en'; + } + + const saved = window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (saved) { + return coerceLocale(saved); + } + + return coerceLocale(window.navigator.language); + }); + + useEffect(() => { + setLocale(locale); + if (typeof window !== 'undefined') { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale); + } + }, [locale]); const setAppLocale = (newLocale: Locale) => { setLocaleState(newLocale); - setLocale(newLocale); }; // Listen for 401 events to force logout @@ -100,8 +119,11 @@ function AppContent() { if (loading) { return ( -
-

Connecting...

+
+
+
+

Connecting...

+
); } diff --git a/web/src/components/config/configSections.ts b/web/src/components/config/configSections.ts index 2304acd8c..6ce4feb86 100644 --- a/web/src/components/config/configSections.ts +++ b/web/src/components/config/configSections.ts @@ -271,7 +271,7 @@ export const CONFIG_SECTIONS: SectionDef[] = [ { key: 'kind', label: 'Kind', type: 'select', defaultValue: 'native', options: [ { value: 'native', label: 'Native' }, { value: 'docker', label: 'Docker' }, - { value: 'wasm', label: 'WASM' }, + { value: 'wasm', label: 'Sandboxed' }, ]}, { key: 'reasoning_enabled', label: 'Reasoning Enabled', type: 'toggle', description: 'Enable model reasoning mode' }, ], @@ -975,8 +975,8 @@ export const CONFIG_SECTIONS: SectionDef[] = [ { path: 'wasm', category: 'runtime', - title: 'WASM Plugins', - description: 'WebAssembly plugin engine', + title: 'Plugin Engine', + description: 'Sandboxed plugin engine', icon: Play, defaultCollapsed: true, fields: [ diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 036b71315..0d6534648 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,9 +1,10 @@ import { useLocation } from 'react-router-dom'; -import { LogOut, Menu } from 'lucide-react'; -import { t } from '@/lib/i18n'; -import type { Locale } from '@/lib/i18n'; +import { useState } from 'react'; +import { LogOut, Menu, TestTubeDiagonal, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; +import { t, LANGUAGE_BUTTON_LABELS, LANGUAGE_SWITCH_ORDER } from '@/lib/i18n'; import { useLocaleContext } from '@/App'; import { useAuth } from '@/hooks/useAuth'; +import { isMockModeEnabled, setMockModeEnabled } from '@/lib/mockMode'; const routeTitles: Record = { '/': 'nav.dashboard', @@ -12,59 +13,111 @@ const routeTitles: Record = { '/cron': 'nav.cron', '/integrations': 'nav.integrations', '/memory': 'nav.memory', + '/devices': 'nav.devices', '/config': 'nav.config', '/cost': 'nav.cost', '/logs': 'nav.logs', '/doctor': 'nav.doctor', }; -const localeCycle: Locale[] = ['en', 'tr', 'zh-CN']; +const languageSummary = 'English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά'; interface HeaderProps { + isSidebarCollapsed: boolean; onToggleSidebar: () => void; + onToggleSidebarCollapse: () => void; } -export default function Header({ onToggleSidebar }: HeaderProps) { +export default function Header({ + isSidebarCollapsed, + onToggleSidebar, + onToggleSidebarCollapse, +}: HeaderProps) { const location = useLocation(); const { logout } = useAuth(); const { locale, setAppLocale } = useLocaleContext(); + const [mockMode, setMockMode] = useState(() => isMockModeEnabled()); const titleKey = routeTitles[location.pathname] ?? 'nav.dashboard'; const pageTitle = t(titleKey); const toggleLanguage = () => { - const currentIndex = localeCycle.indexOf(locale); - const nextLocale = localeCycle[(currentIndex + 1) % localeCycle.length] ?? 'en'; + const currentIndex = LANGUAGE_SWITCH_ORDER.indexOf(locale); + const nextLocale = + LANGUAGE_SWITCH_ORDER[(currentIndex + 1) % LANGUAGE_SWITCH_ORDER.length] ?? 'en'; setAppLocale(nextLocale); }; + const toggleMockMode = () => { + const next = !mockMode; + setMockModeEnabled(next); + setMockMode(next); + window.location.reload(); + }; + return ( -
-
+
+
+ +
-

{pageTitle}

+ +
+

+ {pageTitle} +

+

+ Electric dashboard +

+
-
+
+ + + + + +
+ + +
-