From e77fb11c14e82648d44dd99c5647d0e16c5e378a Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:07:43 +0000 Subject: [PATCH 1/2] Route shared session navigation by Factory access --- app/src/server/server_api/factory.rs | 18 ++++++- app/src/workspace/view.rs | 77 ++++++++++++++++++++-------- app/src/workspace/view/wasm_view.rs | 40 +++++++++++---- app/src/workspace/view_tests.rs | 19 +++++++ 4 files changed, 120 insertions(+), 34 deletions(-) diff --git a/app/src/server/server_api/factory.rs b/app/src/server/server_api/factory.rs index e6f50e6436a..a2040705f40 100644 --- a/app/src/server/server_api/factory.rs +++ b/app/src/server/server_api/factory.rs @@ -3,6 +3,8 @@ use async_trait::async_trait; use cynic::{MutationBuilder, QueryBuilder}; #[cfg(test)] use mockall::automock; +#[cfg(target_family = "wasm")] +use serde::Deserialize; use warp_graphql::mutations::delete_runner::{ DeleteRunner, DeleteRunnerInput, DeleteRunnerResult, DeleteRunnerVariables, }; @@ -26,11 +28,20 @@ pub struct UpsertedRunner { pub is_update: bool, } -/// Client for the Factory GraphQL surface (runner CRUD). +#[cfg(target_family = "wasm")] +#[derive(Debug, Deserialize)] +struct FactoryAccessResponse { + allowed: bool, +} + +/// Client for the Factory API surface. #[cfg_attr(test, automock)] #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] pub trait FactoryClient: 'static + Send + Sync { + #[cfg(target_family = "wasm")] + /// Returns whether the current principal is in the Factory early-access rollout. + async fn has_factory_access(&self) -> Result; /// Fetch all runners visible to the caller, optionally sorted. async fn get_runners(&self, sort_by: Option) -> Result>; @@ -47,6 +58,11 @@ pub trait FactoryClient: 'static + Send + Sync { #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl FactoryClient for ServerApi { + #[cfg(target_family = "wasm")] + async fn has_factory_access(&self) -> Result { + let response: FactoryAccessResponse = self.get_public_api("factory/access").await?; + Ok(response.allowed) + } async fn get_runners(&self, sort_by: Option) -> Result> { let operation = GetRunners::build(GetRunnersVariables { request_context: get_request_context(), diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 2876acf6f1a..9f354236542 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -862,6 +862,34 @@ enum SimplifiedWasmTabBarContent { ConversationTranscript { task_id: Option }, } +#[cfg(any(target_family = "wasm", test))] +#[derive(Clone, Debug, PartialEq, Eq)] +struct SimplifiedWasmProductNavigation { + action_label: &'static str, + destination: String, +} + +#[cfg(any(target_family = "wasm", test))] +impl SimplifiedWasmProductNavigation { + fn from_factory_access(allowed: bool) -> Self { + if allowed { + Self { + action_label: "View Factory", + destination: ChannelState::server_root_url().into_owned(), + } + } else { + Self { + action_label: "View in Oz", + destination: format!("{}/runs", ChannelState::oz_root_url()), + } + } + } + + fn destination(&self) -> &str { + &self.destination + } +} + type RemoteUploadId = (TerminalPaneId, FileUploadId); type WorkspaceMenuHandles = ( ViewHandle>, @@ -1157,7 +1185,9 @@ pub struct Workspace { #[cfg(target_family = "wasm")] open_in_warp_button: ViewHandle, #[cfg(target_family = "wasm")] - view_cloud_runs_button: ViewHandle, + view_product_button: ViewHandle, + #[cfg(target_family = "wasm")] + simplified_wasm_product_navigation: SimplifiedWasmProductNavigation, #[cfg(target_family = "wasm")] transcript_info_button: ViewHandle, #[cfg(target_family = "wasm")] @@ -3260,7 +3290,12 @@ impl Workspace { let transcript_info_button = Self::build_transcript_info_button(ctx); #[cfg(target_family = "wasm")] - let view_cloud_runs_button = Self::build_view_cloud_runs_button(ctx); + let simplified_wasm_product_navigation = + SimplifiedWasmProductNavigation::from_factory_access(false); + + #[cfg(target_family = "wasm")] + let view_product_button = + Self::build_view_product_button(simplified_wasm_product_navigation.clone(), ctx); #[cfg(target_family = "wasm")] let transcript_details_panel = Self::build_transcript_details_panel(ctx); @@ -3516,7 +3551,9 @@ impl Workspace { #[cfg(target_family = "wasm")] transcript_info_button, #[cfg(target_family = "wasm")] - view_cloud_runs_button, + view_product_button, + #[cfg(target_family = "wasm")] + simplified_wasm_product_navigation, #[cfg(target_family = "wasm")] transcript_details_panel, tab_fixed_width: None, @@ -3569,6 +3606,8 @@ impl Workspace { // any) read from `GlobalResourceHandles`. Subsequent updates are // pushed by `subscribe_to_settings_errors` and `dismiss_workspace_banner`. ws.sync_settings_error_state_into_settings_pane(ctx); + #[cfg(target_family = "wasm")] + ws.fetch_factory_access(ctx); let weak_handle = ctx.handle(); WorkspaceRegistry::handle(ctx).update(ctx, |registry, _| { @@ -20865,7 +20904,10 @@ impl Workspace { .with_main_axis_size(MainAxisSize::Max); let bg_color = blended_colors::neutral_1(appearance.theme()); - // Left: Warp logo - clickable to link to warp.dev + let product_destination = self + .simplified_wasm_product_navigation + .destination() + .to_owned(); let warp_logo = Hoverable::new(self.mouse_states.warp_logo.clone(), |_state| { ConstrainedBox::new( warp_core::ui::Icon::Warp @@ -20876,25 +20918,17 @@ impl Workspace { .with_width(24.) .finish() }) - .on_click(|ctx, _, _| { - ctx.dispatch_typed_action(WorkspaceAction::OpenLink("https://warp.dev".to_owned())); + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(WorkspaceAction::OpenLink(product_destination.clone())); }) .with_cursor(Cursor::PointingHand) .finish(); tab_bar.add_child(warp_logo); - // Right: Info button + "View all cloud runs" button (for ambient agent sessions) + "Open in Warp" button let mut right_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min); - // Extract task_id from conversation transcripts and shared sessions - let task_id = match content_type { - SimplifiedWasmTabBarContent::ConversationTranscript { task_id } - | SimplifiedWasmTabBarContent::SharedSession { task_id } => task_id, - SimplifiedWasmTabBarContent::WarpDriveObject => None, - }; - // Show info button for conversation transcripts and shared sessions (if there's content to display) let should_show_info_button = !matches!(content_type, SimplifiedWasmTabBarContent::WarpDriveObject) @@ -20912,15 +20946,14 @@ impl Workspace { .with_margin_right(8.) .finish(), ); + } - // Add "View all cloud runs" button when task_id exists (with 4px gap) - if task_id.is_some() { - right_row.add_child( - Container::new(ChildView::new(&self.view_cloud_runs_button).finish()) - .with_margin_right(4.) - .finish(), - ); - } + if !matches!(content_type, SimplifiedWasmTabBarContent::WarpDriveObject) { + right_row.add_child( + Container::new(ChildView::new(&self.view_product_button).finish()) + .with_margin_right(4.) + .finish(), + ); } // Hide "Open in Warp" button on mobile devices diff --git a/app/src/workspace/view/wasm_view.rs b/app/src/workspace/view/wasm_view.rs index d4bb881c214..c61791f69eb 100644 --- a/app/src/workspace/view/wasm_view.rs +++ b/app/src/workspace/view/wasm_view.rs @@ -1,15 +1,15 @@ //! WASM-only view functions for the Workspace. -use warp_core::channel::ChannelState; use warpui::elements::{ChildView, Element}; use warpui::{AppContext, SingletonEntity, ViewContext, ViewHandle}; -use super::PanelPosition; +use super::{PanelPosition, SimplifiedWasmProductNavigation}; use crate::BlocklistAIHistoryModel; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::conversation_details_panel::{ ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent, }; +use crate::server::server_api::ServerApiProvider; use crate::terminal::TerminalView; use crate::ui_components::icons; use crate::uri::browser_url_handler::parse_current_url; @@ -22,11 +22,6 @@ use crate::workspace::view::{NotebookSource, OpenWarpDriveObjectSettings, Worksp const TRANSCRIPT_PANEL_WIDTH: f32 = 280.0; -/// Builds the OZ runs URL for viewing all cloud runs. -fn build_oz_runs_url() -> String { - format!("{}/runs", ChannelState::oz_root_url()) -} - impl Workspace { pub(super) fn build_wasm_nux_dialog(ctx: &mut ViewContext) -> ViewHandle { let wasm_nux_dialog = ctx.add_typed_action_view(|_| WasmNUXDialog::new()); @@ -54,13 +49,36 @@ impl Workspace { }) } - pub(super) fn build_view_cloud_runs_button( + pub(super) fn fetch_factory_access(&mut self, ctx: &mut ViewContext) { + let factory_client = ServerApiProvider::as_ref(ctx).get_factory_client(); + ctx.spawn( + async move { factory_client.has_factory_access().await }, + |workspace, result, ctx| match result { + Ok(allowed) => { + let navigation = SimplifiedWasmProductNavigation::from_factory_access(allowed); + workspace.view_product_button = + Self::build_view_product_button(navigation.clone(), ctx); + workspace.simplified_wasm_product_navigation = navigation; + ctx.notify(); + } + Err(error) => { + log::warn!( + "Failed to check Factory access; keeping legacy Oz navigation: {error:#}" + ); + } + }, + ); + } + + pub(super) fn build_view_product_button( + navigation: SimplifiedWasmProductNavigation, ctx: &mut ViewContext, ) -> ViewHandle { - let url = build_oz_runs_url(); + let label = navigation.action_label; + let destination = navigation.destination; ctx.add_typed_action_view(|_ctx| { - ActionButton::new("View all cloud runs", SecondaryTheme).on_click(move |ctx| { - ctx.dispatch_typed_action(WorkspaceAction::OpenLink(url.clone())); + ActionButton::new(label, SecondaryTheme).on_click(move |ctx| { + ctx.dispatch_typed_action(WorkspaceAction::OpenLink(destination.clone())); }) }) } diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 63570077665..3b0c4110001 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -90,6 +90,25 @@ use crate::workspaces::user_workspaces::UserWorkspaces; use crate::{ AgentNotificationsModel, GlobalResourceHandlesProvider, ObjectActions, experiments, workspace, }; + +#[test] +fn simplified_wasm_navigation_uses_factory_destination_for_allowed_users() { + let navigation = SimplifiedWasmProductNavigation::from_factory_access(true); + + assert_eq!(navigation.action_label, "View Factory"); + assert_eq!(navigation.destination, ChannelState::server_root_url()); +} + +#[test] +fn simplified_wasm_navigation_falls_back_to_legacy_oz_destination() { + let navigation = SimplifiedWasmProductNavigation::from_factory_access(false); + + assert_eq!(navigation.action_label, "View in Oz"); + assert_eq!( + navigation.destination(), + format!("{}/runs", ChannelState::oz_root_url()) + ); +} pub(crate) fn initialize_app(app: &mut App) { initialize_settings_for_tests(app); From d3ed21e7d278fac42dff9646034544ff7e56e105 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:44:01 +0000 Subject: [PATCH 2/2] test(APP-5796): cover shared-session navigation boundaries --- app/src/server/server_api/factory.rs | 39 +++++- app/src/server/server_api/factory_tests.rs | 48 +++++++ app/src/workspace/view.rs | 69 +++++++--- app/src/workspace/view/wasm_view.rs | 37 +----- app/src/workspace/view_tests.rs | 143 ++++++++++++++++++++- 5 files changed, 279 insertions(+), 57 deletions(-) create mode 100644 app/src/server/server_api/factory_tests.rs diff --git a/app/src/server/server_api/factory.rs b/app/src/server/server_api/factory.rs index a2040705f40..be8ab067220 100644 --- a/app/src/server/server_api/factory.rs +++ b/app/src/server/server_api/factory.rs @@ -1,9 +1,11 @@ +#[cfg(any(target_family = "wasm", test))] +use anyhow::Context; use anyhow::{Result, anyhow}; use async_trait::async_trait; use cynic::{MutationBuilder, QueryBuilder}; #[cfg(test)] use mockall::automock; -#[cfg(target_family = "wasm")] +#[cfg(any(target_family = "wasm", test))] use serde::Deserialize; use warp_graphql::mutations::delete_runner::{ DeleteRunner, DeleteRunnerInput, DeleteRunnerResult, DeleteRunnerVariables, @@ -28,7 +30,7 @@ pub struct UpsertedRunner { pub is_update: bool, } -#[cfg(target_family = "wasm")] +#[cfg(any(target_family = "wasm", test))] #[derive(Debug, Deserialize)] struct FactoryAccessResponse { allowed: bool, @@ -39,7 +41,7 @@ struct FactoryAccessResponse { #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] pub trait FactoryClient: 'static + Send + Sync { - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] /// Returns whether the current principal is in the Factory early-access rollout. async fn has_factory_access(&self) -> Result; /// Fetch all runners visible to the caller, optionally sorted. @@ -58,9 +60,32 @@ pub trait FactoryClient: 'static + Send + Sync { #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl FactoryClient for ServerApi { - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] async fn has_factory_access(&self) -> Result { - let response: FactoryAccessResponse = self.get_public_api("factory/access").await?; + let auth_token = self + .get_or_refresh_access_token() + .await + .context("Failed to get access token for Factory access request")?; + let url = format!( + "{}/api/v1/factory/access", + crate::ChannelState::server_root_url() + ); + let mut request = self.http_client().get(&url); + if let Some(token) = auth_token.as_bearer_token() { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .with_context(|| format!("Failed to send Factory access request to {url}"))?; + if !response.status().is_success() { + self.observe_iap_challenge(&response); + return Err(Self::error_from_response(response).await); + } + let response: FactoryAccessResponse = response + .json() + .await + .with_context(|| format!("Failed to deserialize Factory access response from {url}"))?; Ok(response.allowed) } async fn get_runners(&self, sort_by: Option) -> Result> { @@ -113,3 +138,7 @@ impl FactoryClient for ServerApi { } } } + +#[cfg(test)] +#[path = "factory_tests.rs"] +mod tests; diff --git a/app/src/server/server_api/factory_tests.rs b/app/src/server/server_api/factory_tests.rs new file mode 100644 index 00000000000..d36af9460e0 --- /dev/null +++ b/app/src/server/server_api/factory_tests.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use futures::executor::block_on; + +use super::FactoryClient; +use crate::ChannelState; +use crate::auth::auth_state::AuthState; +use crate::server::server_api::ServerApi; +use crate::server::telemetry::TelemetryApi; + +fn server_api_with_bearer_token() -> ServerApi { + let (event_sender, _) = async_channel::unbounded(); + let auth_state = Arc::new(AuthState::new_logged_out_for_test()); + auth_state.set_remote_server_bearer_token("factory-access-token".to_string()); + ServerApi::new_with_parts( + Arc::new(http_client::Client::new_for_test()), + auth_state, + event_sender, + None, + None, + TelemetryApi::new(), + ) +} + +fn factory_access_response(body: &str) -> anyhow::Result { + let _request = { + let mut server = ChannelState::mock_server(); + server + .mock("GET", "/api/v1/factory/access") + .match_header("authorization", "Bearer factory-access-token") + .with_status(200) + .with_body(body) + .create() + }; + + block_on(server_api_with_bearer_token().has_factory_access()) +} + +#[test] +fn factory_access_request_uses_authenticated_endpoint_and_decodes_rollout_state() { + assert!(factory_access_response(r#"{"allowed":true}"#).unwrap()); + assert!(!factory_access_response(r#"{"allowed":false}"#).unwrap()); +} + +#[test] +fn factory_access_request_rejects_malformed_response() { + assert!(factory_access_response(r#"{"access":true}"#).is_err()); +} diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 9f354236542..7f79b89f280 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -336,6 +336,8 @@ use crate::server::cloud_objects::update_manager::{ use crate::server::ids::{ObjectUid, ServerId, SyncId}; use crate::server::network_log_pane_manager::NetworkLogPaneManager; use crate::server::server_api::ai::AIClient; +#[cfg(any(target_family = "wasm", test))] +use crate::server::server_api::factory::FactoryClient; use crate::server::server_api::{ServerApi, ServerApiProvider, ServerTime}; use crate::server::telemetry::{ AddTabWithShellSource, AnonymousUserSignupEntrypoint, CloseTarget, EnvVarTelemetryMetadata, @@ -473,8 +475,8 @@ use crate::util::openable_file_type::{ }; use crate::util::traffic_lights::{TrafficLightMouseStates, TrafficLightSide, traffic_light_data}; use crate::util::truncation::truncate_from_end; -#[cfg(target_family = "wasm")] -use crate::view_components::action_button::ActionButton; +#[cfg(any(target_family = "wasm", test))] +use crate::view_components::action_button::{ActionButton, SecondaryTheme}; use crate::view_components::callout_bubble::{ CalloutArrowDirection, CalloutArrowPosition, CalloutBubbleConfig, render_callout_bubble, }; @@ -885,8 +887,8 @@ impl SimplifiedWasmProductNavigation { } } - fn destination(&self) -> &str { - &self.destination + fn open_action(&self) -> WorkspaceAction { + WorkspaceAction::OpenLink(self.destination.clone()) } } @@ -1184,9 +1186,9 @@ pub struct Workspace { wasm_nux_dialog: ViewHandle, #[cfg(target_family = "wasm")] open_in_warp_button: ViewHandle, - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] view_product_button: ViewHandle, - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] simplified_wasm_product_navigation: SimplifiedWasmProductNavigation, #[cfg(target_family = "wasm")] transcript_info_button: ViewHandle, @@ -1250,6 +1252,45 @@ pub struct Workspace { } impl Workspace { + #[cfg(any(target_family = "wasm", test))] + fn build_view_product_button( + navigation: SimplifiedWasmProductNavigation, + ctx: &mut ViewContext, + ) -> ViewHandle { + let label = navigation.action_label; + let action = navigation.open_action(); + ctx.add_typed_action_view(|_| { + ActionButton::new(label, SecondaryTheme).on_click(move |ctx| { + let action = action.clone(); + ctx.dispatch_typed_action(action); + }) + }) + } + + #[cfg(any(target_family = "wasm", test))] + fn fetch_factory_access_with_client( + &mut self, + factory_client: Arc, + ctx: &mut ViewContext, + ) { + ctx.spawn( + async move { factory_client.has_factory_access().await }, + |workspace, result, ctx| match result { + Ok(allowed) => { + let navigation = SimplifiedWasmProductNavigation::from_factory_access(allowed); + workspace.view_product_button = + Self::build_view_product_button(navigation.clone(), ctx); + workspace.simplified_wasm_product_navigation = navigation; + ctx.notify(); + } + Err(error) => { + log::warn!( + "Failed to check Factory access; keeping legacy Oz navigation: {error:#}" + ); + } + }, + ); + } /// Whether this workspace was opened directly against a shared session, cloud /// conversation, or similar deep-linked content. pub(crate) fn opened_from_content_deep_link(&self) -> bool { @@ -3289,11 +3330,11 @@ impl Workspace { #[cfg(target_family = "wasm")] let transcript_info_button = Self::build_transcript_info_button(ctx); - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] let simplified_wasm_product_navigation = SimplifiedWasmProductNavigation::from_factory_access(false); - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] let view_product_button = Self::build_view_product_button(simplified_wasm_product_navigation.clone(), ctx); @@ -3550,9 +3591,9 @@ impl Workspace { open_in_warp_button, #[cfg(target_family = "wasm")] transcript_info_button, - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] view_product_button, - #[cfg(target_family = "wasm")] + #[cfg(any(target_family = "wasm", test))] simplified_wasm_product_navigation, #[cfg(target_family = "wasm")] transcript_details_panel, @@ -20904,10 +20945,7 @@ impl Workspace { .with_main_axis_size(MainAxisSize::Max); let bg_color = blended_colors::neutral_1(appearance.theme()); - let product_destination = self - .simplified_wasm_product_navigation - .destination() - .to_owned(); + let open_product_action = self.simplified_wasm_product_navigation.open_action(); let warp_logo = Hoverable::new(self.mouse_states.warp_logo.clone(), |_state| { ConstrainedBox::new( warp_core::ui::Icon::Warp @@ -20919,7 +20957,8 @@ impl Workspace { .finish() }) .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(WorkspaceAction::OpenLink(product_destination.clone())); + let open_product_action = open_product_action.clone(); + ctx.dispatch_typed_action(open_product_action); }) .with_cursor(Cursor::PointingHand) .finish(); diff --git a/app/src/workspace/view/wasm_view.rs b/app/src/workspace/view/wasm_view.rs index c61791f69eb..5f6cae526b1 100644 --- a/app/src/workspace/view/wasm_view.rs +++ b/app/src/workspace/view/wasm_view.rs @@ -3,7 +3,7 @@ use warpui::elements::{ChildView, Element}; use warpui::{AppContext, SingletonEntity, ViewContext, ViewHandle}; -use super::{PanelPosition, SimplifiedWasmProductNavigation}; +use super::PanelPosition; use crate::BlocklistAIHistoryModel; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::conversation_details_panel::{ @@ -13,9 +13,7 @@ use crate::server::server_api::ServerApiProvider; use crate::terminal::TerminalView; use crate::ui_components::icons; use crate::uri::browser_url_handler::parse_current_url; -use crate::view_components::action_button::{ - ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme, -}; +use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, PrimaryTheme}; use crate::wasm_nux_dialog::{WasmNUXDialog, WasmNUXDialogEvent}; use crate::workspace::action::WorkspaceAction; use crate::workspace::view::{NotebookSource, OpenWarpDriveObjectSettings, Workspace}; @@ -51,36 +49,7 @@ impl Workspace { pub(super) fn fetch_factory_access(&mut self, ctx: &mut ViewContext) { let factory_client = ServerApiProvider::as_ref(ctx).get_factory_client(); - ctx.spawn( - async move { factory_client.has_factory_access().await }, - |workspace, result, ctx| match result { - Ok(allowed) => { - let navigation = SimplifiedWasmProductNavigation::from_factory_access(allowed); - workspace.view_product_button = - Self::build_view_product_button(navigation.clone(), ctx); - workspace.simplified_wasm_product_navigation = navigation; - ctx.notify(); - } - Err(error) => { - log::warn!( - "Failed to check Factory access; keeping legacy Oz navigation: {error:#}" - ); - } - }, - ); - } - - pub(super) fn build_view_product_button( - navigation: SimplifiedWasmProductNavigation, - ctx: &mut ViewContext, - ) -> ViewHandle { - let label = navigation.action_label; - let destination = navigation.destination; - ctx.add_typed_action_view(|_ctx| { - ActionButton::new(label, SecondaryTheme).on_click(move |ctx| { - ctx.dispatch_typed_action(WorkspaceAction::OpenLink(destination.clone())); - }) - }) + self.fetch_factory_access_with_client(factory_client, ctx); } pub(super) fn build_transcript_info_button( diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 3b0c4110001..5ef2ef29fdd 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; use ai::project_context::model::ProjectContextModel; @@ -59,6 +60,7 @@ use crate::server::cloud_objects::listener::Listener; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::experiments::ServerExperiments; use crate::server::server_api::ServerApiProvider; +use crate::server::server_api::factory::MockFactoryClient; use crate::server::sync_queue::SyncQueue; use crate::server::telemetry::context_provider::AppTelemetryContextProvider; use crate::settings::PrivacySettings; @@ -104,11 +106,146 @@ fn simplified_wasm_navigation_falls_back_to_legacy_oz_destination() { let navigation = SimplifiedWasmProductNavigation::from_factory_access(false); assert_eq!(navigation.action_label, "View in Oz"); - assert_eq!( - navigation.destination(), - format!("{}/runs", ChannelState::oz_root_url()) + assert_open_link_action( + &navigation, + &format!("{}/runs", ChannelState::oz_root_url()), ); } + +fn assert_open_link_action(navigation: &SimplifiedWasmProductNavigation, expected: &str) { + match navigation.open_action() { + WorkspaceAction::OpenLink(destination) => assert_eq!(destination, expected), + _ => panic!("expected OpenLink action"), + } +} + +#[test] +fn factory_access_updates_workspace_navigation_asynchronously() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let legacy_destination = format!("{}/runs", ChannelState::oz_root_url()); + let initial_button_id = workspace.read(&app, |workspace, _| { + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + &legacy_destination, + ); + workspace.view_product_button.id() + }); + + let mut factory_client = MockFactoryClient::new(); + factory_client + .expect_has_factory_access() + .once() + .return_once(|| Ok(true)); + workspace.update(&mut app, |workspace, ctx| { + workspace.fetch_factory_access_with_client(Arc::new(factory_client), ctx); + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_eq!(workspace.view_product_button.id(), initial_button_id); + }); + + crate::test_util::assert_eventually!( + workspace.read(&app, |workspace, _| { + workspace.simplified_wasm_product_navigation.action_label == "View Factory" + && workspace.view_product_button.id() != initial_button_id + }), + "Factory access did not update the workspace navigation" + ); + workspace.read(&app, |workspace, _| { + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + ChannelState::server_root_url().as_ref(), + ); + }); + }); +} + +#[test] +fn denied_factory_access_keeps_legacy_destination_and_refreshes_button() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let initial_button_id = + workspace.read(&app, |workspace, _| workspace.view_product_button.id()); + let mut factory_client = MockFactoryClient::new(); + factory_client + .expect_has_factory_access() + .once() + .return_once(|| Ok(false)); + + workspace.update(&mut app, |workspace, ctx| { + workspace.fetch_factory_access_with_client(Arc::new(factory_client), ctx); + }); + + crate::test_util::assert_eventually!( + workspace.read(&app, |workspace, _| { + workspace.view_product_button.id() != initial_button_id + }), + "Denied Factory access did not complete" + ); + workspace.read(&app, |workspace, _| { + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + &format!("{}/runs", ChannelState::oz_root_url()), + ); + }); + }); +} + +#[test] +fn failed_factory_access_preserves_loading_fallback() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let initial_button_id = + workspace.read(&app, |workspace, _| workspace.view_product_button.id()); + let probe_completed = Arc::new(AtomicBool::new(false)); + let probe_completed_for_mock = probe_completed.clone(); + let mut factory_client = MockFactoryClient::new(); + factory_client + .expect_has_factory_access() + .once() + .return_once(move || { + probe_completed_for_mock.store(true, Ordering::SeqCst); + Err(anyhow::anyhow!("malformed response")) + }); + + workspace.update(&mut app, |workspace, ctx| { + workspace.fetch_factory_access_with_client(Arc::new(factory_client), ctx); + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + }); + + crate::test_util::assert_eventually!( + probe_completed.load(Ordering::SeqCst), + "Failed Factory access probe did not complete" + ); + workspace.read(&app, |workspace, _| { + assert_eq!(workspace.view_product_button.id(), initial_button_id); + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + &format!("{}/runs", ChannelState::oz_root_url()), + ); + }); + }); +} pub(crate) fn initialize_app(app: &mut App) { initialize_settings_for_tests(app);