From f053aeb4ddf8edf24ea1d30c405ce027c4b3026d Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Thu, 8 Jan 2026 13:13:57 +0100 Subject: [PATCH] remote: Introduce a proper mock remote connection (#46337) Release Notes: - N/A *or* Added/Fixed/Improved ... --- Cargo.lock | 1 + crates/collab/Cargo.toml | 1 + .../remote_editing_collaboration_tests.rs | 2 +- crates/collab_ui/Cargo.toml | 1 + crates/dap/src/client.rs | 4 +- crates/dap/src/transport.rs | 5 +- crates/git_ui/Cargo.toml | 2 +- crates/project/Cargo.toml | 2 + crates/project/src/trusted_worktrees.rs | 4 + crates/recent_projects/Cargo.toml | 5 + crates/recent_projects/src/recent_projects.rs | 2 + .../recent_projects/src/remote_connections.rs | 12 + crates/recent_projects/src/remote_servers.rs | 5 + crates/remote/src/remote.rs | 5 + crates/remote/src/remote_client.rs | 303 ++++------------- crates/remote/src/transport.rs | 2 + crates/remote/src/transport/mock.rs | 312 ++++++++++++++++++ .../remote_server/src/remote_editing_tests.rs | 2 +- crates/title_bar/Cargo.toml | 2 + crates/title_bar/src/title_bar.rs | 2 + crates/workspace/src/persistence.rs | 5 + crates/zed/Cargo.toml | 1 + 22 files changed, 426 insertions(+), 254 deletions(-) create mode 100644 crates/remote/src/transport/mock.rs diff --git a/Cargo.lock b/Cargo.lock index 886bdbb3113617665169bd12c83f26093913d761..611165099c6e615174f437f0768f00850b43f14f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3372,6 +3372,7 @@ dependencies = [ "text", "theme", "time", + "title_bar", "tokio", "toml 0.8.23", "tower 0.4.13", diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 3b363054e3fadf58412101cdfa51b609307cbb5c..e36d3d52ddf335f1d2e5a8ae517e58cf245ebb26 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -125,6 +125,7 @@ smol.workspace = true sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true theme.workspace = true +title_bar = { workspace = true, features = ["test-support"] } unindent.workspace = true util.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/collab/src/tests/remote_editing_collaboration_tests.rs b/crates/collab/src/tests/remote_editing_collaboration_tests.rs index e022c0a8e77b513bb165ca3595104ed2e5af47b7..4ac186d0c9f687a0a5483be88a306f1d2120285b 100644 --- a/crates/collab/src/tests/remote_editing_collaboration_tests.rs +++ b/crates/collab/src/tests/remote_editing_collaboration_tests.rs @@ -676,7 +676,7 @@ async fn test_remote_server_debugger( }); session.update(cx_a, |session, _| { - assert_eq!(session.binary().unwrap().command.as_deref(), Some("ssh")); + assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock")); }); let shutdown_session = workspace.update(cx_a, |workspace, cx| { diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 4abeb1324c28f73585dea4c60fe185ca7b2317ad..40aa29f9b81cee9e01068925e036991650e1258b 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -26,6 +26,7 @@ test-support = [ "util/test-support", "workspace/test-support", "http_client/test-support", + "title_bar/test-support", ] [dependencies] diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs index 0e3db35107836adfed411415582ff89f46706f36..aa2e53f43cb13511e7eee2e9685a4939b07243b9 100644 --- a/crates/dap/src/client.rs +++ b/crates/dap/src/client.rs @@ -59,7 +59,9 @@ impl DebugAdapterClient { pub fn should_reconnect_for_ssh(&self) -> bool { self.transport_delegate.tcp_arguments().is_some() - && self.binary.command.as_deref() == Some("ssh") + && (self.binary.command.as_deref() == Some("ssh") + || (cfg!(feature = "test-support") + && self.binary.command.as_deref() == Some("mock"))) } pub async fn connect( diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs index 3e616fb3f451d5a63ba69d1220f1d06f21cce957..2bb55af74ec6ff2a8eaa842aac002dff21746d29 100644 --- a/crates/dap/src/transport.rs +++ b/crates/dap/src/transport.rs @@ -221,10 +221,7 @@ impl TransportDelegate { })); } - { - let mut lock = self.server_tx.lock().await; - *lock = Some(server_tx.clone()); - } + *self.server_tx.lock().await = Some(server_tx.clone()); Ok(()) } diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index c88244a036767be0ef862e74faa2113d54125443..8beee0fbd7a0424a0f9ec41d9b22596f7d2b186b 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -13,7 +13,7 @@ name = "git_ui" path = "src/git_ui.rs" [features] -test-support = ["multi_buffer/test-support"] +test-support = ["multi_buffer/test-support", "recent_projects/test-support"] [dependencies] agent_settings.workspace = true diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 73aebb9c2308d72ac7f6e74f0c9eb07ccac7dcab..e7480172a2322c4b296be65b3627735b70b3f9e0 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -17,6 +17,7 @@ test-support = [ "buffer_diff/test-support", "client/test-support", "language/test-support", + "remote/test-support", "settings/test-support", "snippet_provider/test-support", "text/test-support", @@ -117,6 +118,7 @@ snippet_provider = { workspace = true, features = ["test-support"] } unindent.workspace = true util = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } +remote = { workspace = true, features = ["test-support"] } [package.metadata.cargo-machete] ignored = ["tracing"] diff --git a/crates/project/src/trusted_worktrees.rs b/crates/project/src/trusted_worktrees.rs index c30575019bf07aac5ef93cc623fdb1a33833d11d..618aef653e0cbede7ca9bc366f5958a78e8d87de 100644 --- a/crates/project/src/trusted_worktrees.rs +++ b/crates/project/src/trusted_worktrees.rs @@ -162,6 +162,10 @@ impl From for RemoteHostLocation { Some(SharedString::new(docker_connection_options.name)), SharedString::new(docker_connection_options.container_id), ), + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(mock) => { + (None, SharedString::new(format!("mock-{}", mock.id))) + } }; Self { user_name, diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index feaf511b81c73bbf50aae6387b3114b1d96f04c4..f79c84019500af21de23823af68073608e57d3e5 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -12,6 +12,10 @@ workspace = true path = "src/recent_projects.rs" doctest = false +[features] +default = [] +test-support = ["remote/test-support"] + [dependencies] anyhow.workspace = true askpass.workspace = true @@ -60,3 +64,4 @@ project = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } +remote = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 04f820cf0af0fb484b1daf28ea88f37fee344e0f..524677da8917d51637c26c6cd766bdeb57920d43 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -719,6 +719,8 @@ impl PickerDelegate for RecentProjectsDelegate { RemoteConnectionOptions::Ssh { .. } => IconName::Server, RemoteConnectionOptions::Wsl { .. } => IconName::Linux, RemoteConnectionOptions::Docker(_) => IconName::Box, + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(_) => IconName::Server, }) .color(Color::Muted) .into_any_element() diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 6148da270b1e9c181a8b0348835ce147331e47f4..888ac26ce011da8fa1a36757f26db27ffd575d85 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -309,6 +309,10 @@ impl RemoteConnectionModal { (options.distro_name.clone(), None, true, false) } RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true), + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(options) => { + (format!("mock-{}", options.id), None, false, false) + } }; Self { prompt: cx.new(|cx| { @@ -720,6 +724,10 @@ pub async fn open_remote_project( RemoteConnectionOptions::Docker(_) => { "Failed to connect to Dev Container" } + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(_) => { + "Failed to connect to mock server" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], @@ -779,6 +787,10 @@ pub async fn open_remote_project( RemoteConnectionOptions::Docker(_) => { "Failed to connect to Dev Container" } + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(_) => { + "Failed to connect to mock server" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 1191af118357ccc0b3834735514ef6cd41f13479..a5e9831b409719152e886939d88c29bdc639127d 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -245,6 +245,11 @@ impl ProjectPicker { connection_string: "".into(), nickname: None, }, + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(options) => ProjectPickerData::Ssh { + connection_string: format!("mock-{}", options.id).into(), + nickname: None, + }, }; let _path_task = cx .spawn_in(window, { diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 2db918ecce331acac91bb974df1b784f0d6532b3..ceeeb4e6e171a6c50560f03acb0e39eeaed1f469 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -14,3 +14,8 @@ pub use remote_client::{ pub use transport::docker::DockerConnectionOptions; pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption}; pub use transport::wsl::WslConnectionOptions; + +#[cfg(any(test, feature = "test-support"))] +pub use transport::mock::{ + MockConnection, MockConnectionOptions, MockConnectionRegistry, MockDelegate, +}; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 3d6ccc97716e33284e507d35088f9b3b09bb8370..41394783946349922d1de2a63034387688dd1dee 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -958,71 +958,58 @@ impl RemoteClient { pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> { let opts = self.connection_options(); client_cx.spawn(async move |cx| { - let connection = cx - .update_global(|c: &mut ConnectionPool, _| { - if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) { - c.clone() + let connection = cx.update_global(|c: &mut ConnectionPool, _| { + if let Some(ConnectionPoolEntry::Connected(c)) = c.connections.get(&opts) { + if let Some(connection) = c.upgrade() { + connection } else { - panic!("missing test connection") + panic!("connection was dropped") } - }) - .await - .unwrap(); + } else { + panic!("missing test connection") + } + }); connection.simulate_disconnect(cx); }) } + /// Creates a mock connection pair for testing. + /// + /// This is the recommended way to create mock remote connections for tests. + /// It returns both the `MockConnectionOptions` (which can be passed to create + /// a `HeadlessProject`) and an `AnyProtoClient` for the server side. + /// + /// # Example + /// ```ignore + /// let (opts, server_session) = RemoteClient::fake_server(cx, server_cx); + /// // Set up HeadlessProject with server_session... + /// let client = RemoteClient::fake_client(opts, cx).await; + /// ``` #[cfg(any(test, feature = "test-support"))] pub fn fake_server( client_cx: &mut gpui::TestAppContext, server_cx: &mut gpui::TestAppContext, ) -> (RemoteConnectionOptions, AnyProtoClient) { - use crate::transport::ssh::SshConnectionHost; - - let port = client_cx - .update(|cx| cx.default_global::().connections.len() as u16 + 1); - let opts = RemoteConnectionOptions::Ssh(SshConnectionOptions { - host: SshConnectionHost::from("".to_string()), - port: Some(port), - ..Default::default() - }); - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - let server_client = server_cx - .update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server", false)); - let connection: Arc = Arc::new(fake::FakeRemoteConnection { - connection_options: opts.clone(), - server_cx: fake::SendableCx::new(server_cx), - server_channel: server_client.clone(), - }); - - client_cx.update(|cx| { - cx.update_default_global(|c: &mut ConnectionPool, cx| { - c.connections.insert( - opts.clone(), - ConnectionPoolEntry::Connecting( - cx.background_spawn({ - let connection = connection.clone(); - async move { Ok(connection.clone()) } - }) - .shared(), - ), - ); - }) - }); - - (opts, server_client.into()) + use crate::transport::mock::MockConnection; + let (opts, server_client) = MockConnection::new(client_cx, server_cx); + (opts.into(), server_client) } + /// Creates a `RemoteClient` connected to a mock server. + /// + /// Call `fake_server` first to get the connection options, set up the + /// `HeadlessProject` with the server session, then call this method + /// to create the client. #[cfg(any(test, feature = "test-support"))] pub async fn fake_client( opts: RemoteConnectionOptions, client_cx: &mut gpui::TestAppContext, ) -> Entity { + use crate::transport::mock::MockDelegate; let (_tx, rx) = oneshot::channel(); let mut cx = client_cx.to_async(); - let connection = connect(opts, Arc::new(fake::Delegate), &mut cx) + let connection = connect(opts, Arc::new(MockDelegate), &mut cx) .await .unwrap(); client_cx @@ -1031,7 +1018,7 @@ impl RemoteClient { ConnectionIdentifier::setup(), connection, rx, - Arc::new(fake::Delegate), + Arc::new(MockDelegate), cx, ) }) @@ -1107,6 +1094,17 @@ impl ConnectionPool { .await .map(|connection| Arc::new(connection) as Arc) } + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(opts) => { + cx.update(|cx| { + cx.default_global::() + .take(&opts) + .ok_or_else(|| anyhow!( + "Mock connection not found. Call MockConnection::new() first." + )) + .map(|connection| connection as Arc) + }) + } }; cx.update_global(|pool: &mut Self, _| { @@ -1143,6 +1141,8 @@ pub enum RemoteConnectionOptions { Ssh(SshConnectionOptions), Wsl(WslConnectionOptions), Docker(DockerConnectionOptions), + #[cfg(any(test, feature = "test-support"))] + Mock(crate::transport::mock::MockConnectionOptions), } impl RemoteConnectionOptions { @@ -1151,6 +1151,8 @@ impl RemoteConnectionOptions { RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(), RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), RemoteConnectionOptions::Docker(opts) => opts.name.clone(), + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(opts) => format!("mock-{}", opts.id), } } } @@ -1167,6 +1169,13 @@ impl From for RemoteConnectionOptions { } } +#[cfg(any(test, feature = "test-support"))] +impl From for RemoteConnectionOptions { + fn from(opts: crate::transport::mock::MockConnectionOptions) -> Self { + RemoteConnectionOptions::Mock(opts) + } +} + #[cfg(target_os = "windows")] /// Open a wsl path (\\wsl.localhost\\path) #[derive(Debug, Clone, PartialEq, Eq, gpui::Action)] @@ -1254,7 +1263,7 @@ impl Signal { } } -struct ChannelClient { +pub(crate) struct ChannelClient { next_message_id: AtomicU32, outgoing_tx: Mutex>, buffer: Mutex>, @@ -1268,7 +1277,7 @@ struct ChannelClient { } impl ChannelClient { - fn new( + pub(crate) fn new( incoming_rx: mpsc::UnboundedReceiver, outgoing_tx: mpsc::UnboundedSender, cx: &App, @@ -1419,7 +1428,7 @@ impl ChannelClient { }) } - fn reconnect( + pub(crate) fn reconnect( self: &Arc, incoming_rx: UnboundedReceiver, outgoing_tx: UnboundedSender, @@ -1573,201 +1582,3 @@ impl ProtoClient for ChannelClient { self.has_wsl_interop } } - -#[cfg(any(test, feature = "test-support"))] -mod fake { - use super::{ChannelClient, RemoteClientDelegate, RemoteConnection, RemotePlatform}; - use crate::remote_client::{CommandTemplate, RemoteConnectionOptions}; - use anyhow::Result; - use askpass::EncryptedPassword; - use async_trait::async_trait; - use collections::HashMap; - use futures::{ - FutureExt, SinkExt, StreamExt, - channel::{ - mpsc::{self, Sender}, - oneshot, - }, - select_biased, - }; - use gpui::{App, AppContext as _, AsyncApp, Task, TestAppContext}; - use release_channel::ReleaseChannel; - use rpc::proto::Envelope; - use semver::Version; - use std::{path::PathBuf, sync::Arc}; - use util::paths::{PathStyle, RemotePathBuf}; - - pub(super) struct FakeRemoteConnection { - pub(super) connection_options: RemoteConnectionOptions, - pub(super) server_channel: Arc, - pub(super) server_cx: SendableCx, - } - - pub(super) struct SendableCx(AsyncApp); - impl SendableCx { - // SAFETY: When run in test mode, GPUI is always single threaded. - pub(super) fn new(cx: &TestAppContext) -> Self { - Self(cx.to_async()) - } - - // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp - fn get(&self, _: &AsyncApp) -> AsyncApp { - self.0.clone() - } - } - - // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`] - unsafe impl Send for SendableCx {} - unsafe impl Sync for SendableCx {} - - #[async_trait(?Send)] - impl RemoteConnection for FakeRemoteConnection { - async fn kill(&self) -> Result<()> { - Ok(()) - } - - fn has_been_killed(&self) -> bool { - false - } - - fn build_command( - &self, - program: Option, - args: &[String], - env: &HashMap, - _: Option, - _: Option<(u16, String, u16)>, - ) -> Result { - let ssh_program = program.unwrap_or_else(|| "sh".to_string()); - let mut ssh_args = Vec::new(); - ssh_args.push(ssh_program); - ssh_args.extend(args.iter().cloned()); - Ok(CommandTemplate { - program: "ssh".into(), - args: ssh_args, - env: env.clone(), - }) - } - - fn build_forward_ports_command( - &self, - forwards: Vec<(u16, String, u16)>, - ) -> anyhow::Result { - Ok(CommandTemplate { - program: "ssh".into(), - args: std::iter::once("-N".to_owned()) - .chain(forwards.into_iter().map(|(local_port, host, remote_port)| { - format!("{local_port}:{host}:{remote_port}") - })) - .collect(), - env: Default::default(), - }) - } - - fn upload_directory( - &self, - _src_path: PathBuf, - _dest_path: RemotePathBuf, - _cx: &App, - ) -> Task> { - unreachable!() - } - - fn connection_options(&self) -> RemoteConnectionOptions { - self.connection_options.clone() - } - - fn simulate_disconnect(&self, cx: &AsyncApp) { - let (outgoing_tx, _) = mpsc::unbounded::(); - let (_, incoming_rx) = mpsc::unbounded::(); - self.server_channel - .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); - } - - fn start_proxy( - &self, - _unique_identifier: String, - _reconnect: bool, - mut client_incoming_tx: mpsc::UnboundedSender, - mut client_outgoing_rx: mpsc::UnboundedReceiver, - mut connection_activity_tx: Sender<()>, - _delegate: Arc, - cx: &mut AsyncApp, - ) -> Task> { - let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); - let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); - - self.server_channel.reconnect( - server_incoming_rx, - server_outgoing_tx, - &self.server_cx.get(cx), - ); - - cx.background_spawn(async move { - loop { - select_biased! { - server_to_client = server_outgoing_rx.next().fuse() => { - let Some(server_to_client) = server_to_client else { - return Ok(1) - }; - connection_activity_tx.try_send(()).ok(); - client_incoming_tx.send(server_to_client).await.ok(); - } - client_to_server = client_outgoing_rx.next().fuse() => { - let Some(client_to_server) = client_to_server else { - return Ok(1) - }; - server_incoming_tx.send(client_to_server).await.ok(); - } - } - } - }) - } - - fn path_style(&self) -> PathStyle { - PathStyle::local() - } - - fn shell(&self) -> String { - "sh".to_owned() - } - - fn default_system_shell(&self) -> String { - "sh".to_owned() - } - - fn has_wsl_interop(&self) -> bool { - false - } - } - - pub(super) struct Delegate; - - impl RemoteClientDelegate for Delegate { - fn ask_password(&self, _: String, _: oneshot::Sender, _: &mut AsyncApp) { - unreachable!() - } - - fn download_server_binary_locally( - &self, - _: RemotePlatform, - _: ReleaseChannel, - _: Option, - _: &mut AsyncApp, - ) -> Task> { - unreachable!() - } - - fn get_download_url( - &self, - _platform: RemotePlatform, - _release_channel: ReleaseChannel, - _version: Option, - _cx: &mut AsyncApp, - ) -> Task>> { - unreachable!() - } - - fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {} - } -} diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs index 2dedf7ace0d7eab7daf34cc8e183f84ef5f9126a..8031e27ebc9d8ecfccc341f4099abe52f68405f6 100644 --- a/crates/remote/src/transport.rs +++ b/crates/remote/src/transport.rs @@ -13,6 +13,8 @@ use rpc::proto::Envelope; use smol::process::Child; pub mod docker; +#[cfg(any(test, feature = "test-support"))] +pub mod mock; pub mod ssh; pub mod wsl; diff --git a/crates/remote/src/transport/mock.rs b/crates/remote/src/transport/mock.rs new file mode 100644 index 0000000000000000000000000000000000000000..5d392fd685903632fd786b4a95cf737e71e517dd --- /dev/null +++ b/crates/remote/src/transport/mock.rs @@ -0,0 +1,312 @@ +//! Mock transport for testing remote connections. +//! +//! This module provides a mock implementation of the `RemoteConnection` trait +//! that allows testing remote editing functionality without actual SSH/WSL/Docker +//! connections. +//! +//! # Usage +//! +//! ```rust,ignore +//! use remote::{MockConnection, RemoteClient}; +//! +//! #[gpui::test] +//! async fn test_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) { +//! let (opts, server_session) = MockConnection::new(cx, server_cx); +//! +//! // Create the headless project (server side) +//! server_cx.update(HeadlessProject::init); +//! let _headless = server_cx.new(|cx| { +//! HeadlessProject::new( +//! HeadlessAppState { session: server_session, /* ... */ }, +//! false, +//! cx, +//! ) +//! }); +//! +//! // Create the client using the helper +//! let (client, server_client) = RemoteClient::new_mock(cx, server_cx).await; +//! // ... test logic ... +//! } +//! ``` + +use crate::remote_client::{ + ChannelClient, CommandTemplate, RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, +}; +use anyhow::Result; +use async_trait::async_trait; +use collections::HashMap; +use futures::{ + FutureExt, SinkExt, StreamExt, + channel::mpsc::{self, Sender}, + select_biased, +}; +use gpui::{App, AppContext as _, AsyncApp, Global, Task, TestAppContext}; +use rpc::{AnyProtoClient, proto::Envelope}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; +use util::paths::{PathStyle, RemotePathBuf}; + +/// Unique identifier for a mock connection. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MockConnectionOptions { + pub id: u64, +} + +/// A mock implementation of `RemoteConnection` for testing. +pub struct MockRemoteConnection { + options: MockConnectionOptions, + server_channel: Arc, + server_cx: SendableCx, +} + +/// Wrapper to pass `AsyncApp` across thread boundaries in tests. +/// +/// # Safety +/// +/// This is safe because in test mode, GPUI is always single-threaded and so +/// having access to one async app means being on the same main thread. +pub(crate) struct SendableCx(AsyncApp); + +impl SendableCx { + pub(crate) fn new(cx: &TestAppContext) -> Self { + Self(cx.to_async()) + } + + pub(crate) fn get(&self, _: &AsyncApp) -> AsyncApp { + self.0.clone() + } +} + +// SAFETY: In test mode, GPUI is always single-threaded, and SendableCx +// is only accessed from the main thread via the get() method which +// requires a valid AsyncApp reference. +unsafe impl Send for SendableCx {} +unsafe impl Sync for SendableCx {} + +/// Global registry that holds pre-created mock connections. +/// +/// When `ConnectionPool::connect` is called with `MockConnectionOptions`, +/// it retrieves the connection from this registry. +#[derive(Default)] +pub struct MockConnectionRegistry { + pending: HashMap>, +} + +impl Global for MockConnectionRegistry {} + +impl MockConnectionRegistry { + /// Called by `ConnectionPool::connect` to retrieve a pre-registered mock connection. + pub fn take(&mut self, opts: &MockConnectionOptions) -> Option> { + self.pending.remove(opts) + } +} + +/// Helper for creating mock connection pairs in tests. +pub struct MockConnection; + +impl MockConnection { + /// Creates a new mock connection pair for testing. + /// + /// This function: + /// 1. Creates a unique `MockConnectionOptions` identifier + /// 2. Sets up the server-side channel (returned as `AnyProtoClient`) + /// 3. Creates a `MockRemoteConnection` and registers it in the global registry + /// 4. The connection will be retrieved from the registry when `ConnectionPool::connect` is called + /// + /// Returns: + /// - `MockConnectionOptions` to pass to `remote::connect()` or `RemoteClient` creation + /// - `AnyProtoClient` to pass to `HeadlessProject::new()` as the session + /// + /// # Arguments + /// - `client_cx`: The test context for the client side + /// - `server_cx`: The test context for the server/headless side + pub fn new( + client_cx: &mut TestAppContext, + server_cx: &mut TestAppContext, + ) -> (MockConnectionOptions, AnyProtoClient) { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); + let opts = MockConnectionOptions { id }; + + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + let server_client = server_cx + .update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "mock-server", false)); + + let connection = Arc::new(MockRemoteConnection { + options: opts.clone(), + server_channel: server_client.clone(), + server_cx: SendableCx::new(server_cx), + }); + + client_cx.update(|cx| { + cx.default_global::() + .pending + .insert(opts.clone(), connection); + }); + + (opts, server_client.into()) + } +} + +#[async_trait(?Send)] +impl RemoteConnection for MockRemoteConnection { + async fn kill(&self) -> Result<()> { + Ok(()) + } + + fn has_been_killed(&self) -> bool { + false + } + + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + _working_dir: Option, + _port_forward: Option<(u16, String, u16)>, + ) -> Result { + let shell_program = program.unwrap_or_else(|| "sh".to_string()); + let mut shell_args = Vec::new(); + shell_args.push(shell_program); + shell_args.extend(args.iter().cloned()); + Ok(CommandTemplate { + program: "mock".into(), + args: shell_args, + env: env.clone(), + }) + } + + fn build_forward_ports_command( + &self, + forwards: Vec<(u16, String, u16)>, + ) -> Result { + Ok(CommandTemplate { + program: "mock".into(), + args: std::iter::once("-N".to_owned()) + .chain(forwards.into_iter().map(|(local_port, host, remote_port)| { + format!("{local_port}:{host}:{remote_port}") + })) + .collect(), + env: Default::default(), + }) + } + + fn upload_directory( + &self, + _src_path: PathBuf, + _dest_path: RemotePathBuf, + _cx: &App, + ) -> Task> { + Task::ready(Ok(())) + } + + fn connection_options(&self) -> RemoteConnectionOptions { + RemoteConnectionOptions::Mock(self.options.clone()) + } + + fn simulate_disconnect(&self, cx: &AsyncApp) { + let (outgoing_tx, _) = mpsc::unbounded::(); + let (_, incoming_rx) = mpsc::unbounded::(); + self.server_channel + .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx)); + } + + fn start_proxy( + &self, + _unique_identifier: String, + _reconnect: bool, + mut client_incoming_tx: mpsc::UnboundedSender, + mut client_outgoing_rx: mpsc::UnboundedReceiver, + mut connection_activity_tx: Sender<()>, + _delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::(); + let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::(); + + self.server_channel.reconnect( + server_incoming_rx, + server_outgoing_tx, + &self.server_cx.get(cx), + ); + + cx.background_spawn(async move { + loop { + select_biased! { + server_to_client = server_outgoing_rx.next().fuse() => { + let Some(server_to_client) = server_to_client else { + return Ok(1) + }; + connection_activity_tx.try_send(()).ok(); + client_incoming_tx.send(server_to_client).await.ok(); + } + client_to_server = client_outgoing_rx.next().fuse() => { + let Some(client_to_server) = client_to_server else { + return Ok(1) + }; + server_incoming_tx.send(client_to_server).await.ok(); + } + } + } + }) + } + + fn path_style(&self) -> PathStyle { + PathStyle::local() + } + + fn shell(&self) -> String { + "sh".to_owned() + } + + fn default_system_shell(&self) -> String { + "sh".to_owned() + } + + fn has_wsl_interop(&self) -> bool { + false + } +} + +/// Mock delegate for tests that don't need delegate functionality. +pub struct MockDelegate; + +impl RemoteClientDelegate for MockDelegate { + fn ask_password( + &self, + _prompt: String, + _sender: futures::channel::oneshot::Sender, + _cx: &mut AsyncApp, + ) { + unreachable!("MockDelegate::ask_password should not be called in tests") + } + + fn download_server_binary_locally( + &self, + _platform: crate::RemotePlatform, + _release_channel: release_channel::ReleaseChannel, + _version: Option, + _cx: &mut AsyncApp, + ) -> Task> { + unreachable!("MockDelegate::download_server_binary_locally should not be called in tests") + } + + fn get_download_url( + &self, + _platform: crate::RemotePlatform, + _release_channel: release_channel::ReleaseChannel, + _version: Option, + _cx: &mut AsyncApp, + ) -> Task>> { + unreachable!("MockDelegate::get_download_url should not be called in tests") + } + + fn set_status(&self, _status: Option<&str>, _cx: &mut AsyncApp) {} +} diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index 3e0a8680dd6ce75ffc926bd1f468d3f9aae66f31..801e488f4b669d9a5b0d4c6b2b5a1f7728f2b8a4 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1894,7 +1894,7 @@ async fn test_remote_external_agent_server( assert_eq!( command, AgentServerCommand { - path: "ssh".into(), + path: "mock".into(), args: vec!["foo-cli".into(), "--flag".into()], env: Some(HashMap::from_iter([ ("VAR".into(), "val".into()), diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index b73a586c4436f93c472960c0805fc39a2266abe7..101f804b086f891d00a23978fff35f3e49842232 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -22,8 +22,10 @@ test-support = [ "gpui/test-support", "http_client/test-support", "project/test-support", + "remote/test-support", "util/test-support", "workspace/test-support", + "recent_projects/test-support", ] [dependencies] diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 6693b6acb0b37416a8261e2d004a35366469524b..4390e23e6f3df9be3a4ce48f95f664f9bae27ec8 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -366,6 +366,8 @@ impl TitleBar { RemoteConnectionOptions::Docker(_dev_container_connection) => { (None, "Dev Container", IconName::Box) } + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(_) => (None, "Mock Remote Project", IconName::Server), }; let nickname = nickname.unwrap_or_else(|| host.clone()); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 174daeb4a62c16533ca3e63de5123089b754fd24..e7352816a905e18a6dfc15512797b1ba45fd0109 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1325,6 +1325,11 @@ impl WorkspaceDb { container_id = Some(options.container_id); name = Some(options.name); } + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(options) => { + kind = RemoteConnectionKind::Ssh; + host = Some(format!("mock-{}", options.id)); + } } Self::get_or_create_remote_connection_query( this, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 864d685a650ac16b041da81d14ba9a2fccf29fcb..da2841c46b39ec0bc6402f92a93492275e36d68f 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -23,6 +23,7 @@ test-support = [ "editor/test-support", "terminal_view/test-support", "image_viewer/test-support", + "recent_projects/test-support", ] visual-tests = [ "gpui/test-support",