diff --git a/app/src/app_menus.rs b/app/src/app_menus.rs index 7d995779..5afd527c 100644 --- a/app/src/app_menus.rs +++ b/app/src/app_menus.rs @@ -27,6 +27,7 @@ use itertools::Itertools; use settings::manager::SettingsManager; use settings::Setting as _; use warp_core::brand; +use warp_core::channel::ChannelState; use warp_core::context_flag::ContextFlag; use warp_util::path::user_friendly_path; use warpui::actions::StandardAction; @@ -62,7 +63,7 @@ const MAX_RECENT_REPOS_IN_MENU: usize = 10; /// Creates the root app menu bar pub fn menu_bar(ctx: &mut AppContext) -> MenuBar { - MenuBar::new(vec![ + let mut menus = vec![ make_new_app_menu(ctx), make_new_file_menu(ctx), make_new_edit_menu(ctx), @@ -70,10 +71,14 @@ pub fn menu_bar(ctx: &mut AppContext) -> MenuBar { make_new_tab_menu(ctx), make_new_blocks_menu(ctx), make_new_ai_menu(ctx), - make_new_drive_menu(ctx), - make_new_window_menu(), - make_new_help_menu(), - ]) + ]; + + if ChannelState::cloud_services_available() { + menus.push(make_new_drive_menu(ctx)); + } + + menus.extend([make_new_window_menu(), make_new_help_menu()]); + MenuBar::new(menus) } // Creates the app dock menu @@ -150,11 +155,14 @@ fn make_new_app_menu(ctx: &AppContext) -> Menu { )) } - menu_items.extend([ - MenuItem::Separator, - updateable_custom_item_without_checkmark(CustomAction::ReferAFriend, ctx), - MenuItem::Separator, - ]); + menu_items.push(MenuItem::Separator); + if ChannelState::cloud_services_available() { + menu_items.push(updateable_custom_item_without_checkmark( + CustomAction::ReferAFriend, + ctx, + )); + menu_items.push(MenuItem::Separator); + } let preferences_menu_items = vec![ updateable_custom_item_without_checkmark(CustomAction::ShowSettings, ctx), @@ -377,14 +385,21 @@ fn make_new_edit_menu(ctx: &AppContext) -> Menu { } fn make_new_view_menu(ctx: &AppContext) -> Menu { - let mut items = vec![ - updateable_custom_item_without_checkmark(CustomAction::ToggleWarpDrive, ctx), - MenuItem::Separator, + let mut items = vec![]; + if ChannelState::cloud_services_available() { + items.push(updateable_custom_item_without_checkmark( + CustomAction::ToggleWarpDrive, + ctx, + )); + items.push(MenuItem::Separator); + } + + items.extend([ updateable_custom_item_without_checkmark(CustomAction::CommandPalette, ctx), updateable_custom_item_without_checkmark(CustomAction::NavigationPalette, ctx), updateable_custom_item_without_checkmark(CustomAction::LaunchConfigPalette, ctx), updateable_custom_item_without_checkmark(CustomAction::FilesPalette, ctx), - ]; + ]); if FeatureFlag::AgentViewConversationListView.is_enabled() { items.push(updateable_custom_item_without_checkmark( @@ -598,9 +613,17 @@ fn make_new_blocks_menu(ctx: &AppContext) -> Menu { ctx, )); items.push(MenuItem::Separator); + items.push(updateable_custom_item_without_checkmark( + CustomAction::CreateBlockPermalink, + ctx, + )); + if ChannelState::cloud_services_available() { + items.push(non_updateable_custom_item( + CustomAction::ViewSharedBlocks, + ctx, + )); + } items.extend([ - updateable_custom_item_without_checkmark(CustomAction::CreateBlockPermalink, ctx), - non_updateable_custom_item(CustomAction::ViewSharedBlocks, ctx), updateable_custom_item_without_checkmark(CustomAction::ToggleBookmarkBlock, ctx), updateable_custom_item_without_checkmark(CustomAction::FindWithinBlock, ctx), MenuItem::Separator, diff --git a/app/src/castcodes_public_surface_tests.rs b/app/src/castcodes_public_surface_tests.rs index e5e507c1..432ca8bc 100644 --- a/app/src/castcodes_public_surface_tests.rs +++ b/app/src/castcodes_public_surface_tests.rs @@ -9,6 +9,7 @@ const AI_BLOCK_SOURCE: &str = include_str!("ai/blocklist/block.rs"); const AI_BLOCK_STATUS_BAR_SOURCE: &str = include_str!("ai/blocklist/block/status_bar.rs"); const AI_FACT_RULE_SOURCE: &str = include_str!("ai/facts/view/rule.rs"); const AI_TELEMETRY_BANNER_SOURCE: &str = include_str!("ai/blocklist/telemetry_banner.rs"); +const APP_MENUS_SOURCE: &str = include_str!("app_menus.rs"); const AGENT_VIEW_ZERO_STATE_BLOCK_SOURCE: &str = include_str!("ai/blocklist/agent_view/zero_state_block.rs"); const AGENT_PANEL_MOD_SOURCE: &str = include_str!("agent_panel/mod.rs"); @@ -54,6 +55,117 @@ const WORKFLOW_VIEW_SOURCE: &str = include_str!("workflows/workflow_view.rs"); const WORKSPACE_MOD_SOURCE: &str = include_str!("workspace/mod.rs"); const WORKSPACE_VIEW_SOURCE: &str = include_str!("workspace/view.rs"); +fn function_source<'a>(source: &'a str, function: &str, next_function: &str) -> &'a str { + source + .split_once(function) + .unwrap_or_else(|| panic!("{function} should exist")) + .1 + .split_once(next_function) + .unwrap_or_else(|| panic!("{next_function} should follow {function}")) + .0 +} + +fn matching_closing_brace(source: &str, opening_brace_offset: usize) -> Option { + if source.as_bytes().get(opening_brace_offset) != Some(&b'{') { + return None; + } + + let mut depth = 0; + for (relative_offset, character) in source[opening_brace_offset..].char_indices() { + match character { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(opening_brace_offset + relative_offset); + } + } + _ => {} + } + } + None +} + +fn assert_item_is_cloud_service_only(function: &str, item: &str, description: &str) { + let item_offset = function + .find(item) + .unwrap_or_else(|| panic!("{description} should exist")); + let cloud_gate = "if ChannelState::cloud_services_available()"; + let gate_offset = function[..item_offset] + .rfind(cloud_gate) + .unwrap_or_else(|| panic!("{description} should have a cloud-service gate")); + let gate_open_offset = function[gate_offset..item_offset] + .find('{') + .map(|offset| gate_offset + offset) + .unwrap_or_else(|| panic!("{description} cloud-service gate should open")); + let gate_end_offset = matching_closing_brace(function, gate_open_offset) + .unwrap_or_else(|| panic!("{description} cloud-service gate should close")); + + assert!( + item_offset < gate_end_offset, + "{description} should be hidden when hosted cloud services are unavailable" + ); +} + +#[test] +fn cloud_service_gate_matching_supports_nested_braces() { + let function = r#" + if ChannelState::cloud_services_available() { + if nested_condition { + nested_action(); + } + CustomAction::ReferAFriend; + } + "#; + assert_item_is_cloud_service_only( + function, + "CustomAction::ReferAFriend", + "nested referral action", + ); +} + +#[test] +fn referral_app_menu_item_is_cloud_service_only() { + let app_menu = function_source( + APP_MENUS_SOURCE, + "fn make_new_app_menu", + "fn make_new_file_menu", + ); + assert_item_is_cloud_service_only( + app_menu, + "CustomAction::ReferAFriend", + "referral app menu action", + ); +} + +#[test] +fn oss_app_menus_do_not_build_cloud_only_items() { + let menu_bar = function_source(APP_MENUS_SOURCE, "pub fn menu_bar", "pub fn dock_menu"); + assert_item_is_cloud_service_only(menu_bar, "make_new_drive_menu(ctx)", "Drive app menu"); + + let view_menu = function_source( + APP_MENUS_SOURCE, + "fn make_new_view_menu", + "fn make_new_tab_menu", + ); + assert_item_is_cloud_service_only( + view_menu, + "CustomAction::ToggleWarpDrive", + "Cast Drive view menu action", + ); + + let blocks_menu = function_source( + APP_MENUS_SOURCE, + "fn make_new_blocks_menu", + "fn make_new_drive_menu", + ); + assert_item_is_cloud_service_only( + blocks_menu, + "CustomAction::ViewSharedBlocks", + "shared blocks menu action", + ); +} + #[test] fn public_app_surfaces_use_castcodes_links_and_labels() { assert!(LOGIN_SLIDE_SOURCE.contains("PRIVACY_POLICY_URL")); diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 1558aa37..101b614e 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -23,8 +23,17 @@ pub struct Args { } pub fn main() -> Result<()> { + let args = Args::parse(); + let tests = register_tests(); + let channel = args + .integration_test_name + .as_deref() + .and_then(|test_name| tests.get(test_name)) + .map(|(channel, _)| *channel) + .unwrap_or(Channel::Integration); + ChannelState::set(ChannelState::new( - Channel::Integration, + channel, ChannelConfig { app_id: AppId::new( "dev", @@ -56,8 +65,6 @@ pub fn main() -> Result<()> { }, )); - let args = Args::parse(); - if let Some(command) = &args.command { match command { #[cfg(unix)] @@ -75,14 +82,13 @@ pub fn main() -> Result<()> { } } - let tests = register_tests(); let test_name = args .integration_test_name .as_deref() .expect("Integration test name is required"); println!("Running integration test: {test_name}"); - let Some(builder) = tests.get(test_name).map(|func| func()) else { + let Some(builder) = tests.get(test_name).map(|(_, builder_fn)| builder_fn()) else { panic!("test not found for args: {:#?}", env::args()); }; #[cfg_attr(not(unix), allow(unused_variables))] @@ -109,9 +115,10 @@ pub fn main() -> Result<()> { /// Type of a function that produces an integration test builder. type BoxedBuilderFn = Box Builder>; +type RegisteredTest = (Channel, BoxedBuilderFn); -fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { - let mut tests: HashMap<&str, BoxedBuilderFn> = HashMap::new(); +fn register_tests() -> HashMap<&'static str, RegisteredTest> { + let mut tests: HashMap<&str, RegisteredTest> = HashMap::new(); // A tiny macro to simplify the act of registering a test. This avoids // any inconsistencies between the test function name and the key in the @@ -119,11 +126,16 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { // decide to do so in the future). macro_rules! register_test { ($name:ident) => { - tests.insert(stringify!($name), Box::new(|| $name())); + register_test!($name, Channel::Integration); + }; + ($name:ident, $channel:expr) => { + tests.insert(stringify!($name), ($channel, Box::new(|| $name()))); }; } // Add new tests here + #[cfg(target_os = "macos")] + register_test!(test_oss_app_menu_startup, Channel::Oss); register_test!(test_single_command); register_test!(test_add_and_close_session); register_test!(test_add_many_sessions); diff --git a/crates/integration/src/test.rs b/crates/integration/src/test.rs index ceaef49c..f8c670df 100644 --- a/crates/integration/src/test.rs +++ b/crates/integration/src/test.rs @@ -4,6 +4,8 @@ mod agent_mode; mod agent_panel; +#[cfg(target_os = "macos")] +mod app_startup; mod block_filtering; mod bootstrapping; mod code_review; @@ -36,6 +38,8 @@ mod workspace; pub use agent_mode::*; pub use agent_panel::*; +#[cfg(target_os = "macos")] +pub use app_startup::*; pub use block_filtering::*; pub use bootstrapping::*; pub use code_review::*; diff --git a/crates/integration/src/test/app_startup.rs b/crates/integration/src/test/app_startup.rs new file mode 100644 index 00000000..d770ec82 --- /dev/null +++ b/crates/integration/src/test/app_startup.rs @@ -0,0 +1,20 @@ +use crate::Builder; +use warpui::integration::{AssertionOutcome, TestStep}; + +pub fn test_oss_app_menu_startup() -> Builder { + Builder::new().with_step(TestStep::new("Assert OSS app startup").add_named_assertion( + "initial root view exists", + |app, window_id| { + if app + .root_view::(window_id) + .is_some() + { + AssertionOutcome::Success + } else { + AssertionOutcome::failure(format!( + "root view should exist for window_id={window_id}" + )) + } + }, + )) +} diff --git a/crates/integration/tests/integration/ui_tests.rs b/crates/integration/tests/integration/ui_tests.rs index 4dcc81fc..9ee63c5a 100644 --- a/crates/integration/tests/integration/ui_tests.rs +++ b/crates/integration/tests/integration/ui_tests.rs @@ -7,6 +7,8 @@ use super::integration_tests; integration_tests! { + #[cfg(target_os = "macos")] + test_oss_app_menu_startup, test_add_many_sessions, test_daemon_conversation_composer_names_daemon_fix, test_ctrl_tab_session_switching,