logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
214
crates/collab/tests/integration/agent_sharing_tests.rs
Normal file
214
crates/collab/tests/integration/agent_sharing_tests.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use agent::SharedThread;
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use rpc::proto;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::TestServer;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_share_and_retrieve_thread(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
let original_thread = SharedThread {
|
||||
title: "Shared Test Thread".into(),
|
||||
messages: vec![],
|
||||
updated_at: chrono::Utc::now(),
|
||||
model: None,
|
||||
version: SharedThread::VERSION.to_string(),
|
||||
};
|
||||
|
||||
let thread_data = original_thread
|
||||
.to_bytes()
|
||||
.expect("Failed to serialize thread");
|
||||
|
||||
client_a
|
||||
.client()
|
||||
.request(proto::ShareAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
title: original_thread.title.to_string(),
|
||||
thread_data,
|
||||
})
|
||||
.await
|
||||
.expect("Failed to share thread");
|
||||
|
||||
let get_response = client_b
|
||||
.client()
|
||||
.request(proto::GetSharedAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to get shared thread");
|
||||
|
||||
let imported_shared_thread =
|
||||
SharedThread::from_bytes(&get_response.thread_data).expect("Failed to deserialize thread");
|
||||
|
||||
assert_eq!(imported_shared_thread.title, original_thread.title);
|
||||
assert_eq!(imported_shared_thread.version, SharedThread::VERSION);
|
||||
|
||||
let db_thread = imported_shared_thread.to_db_thread();
|
||||
|
||||
assert!(
|
||||
db_thread.title.starts_with("🔗"),
|
||||
"Imported thread title should have link prefix"
|
||||
);
|
||||
assert!(
|
||||
db_thread.title.contains("Shared Test Thread"),
|
||||
"Imported thread should preserve original title"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_reshare_updates_existing_thread(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
client_a
|
||||
.client()
|
||||
.request(proto::ShareAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
title: "Original Title".to_string(),
|
||||
thread_data: b"original data".to_vec(),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to share thread");
|
||||
|
||||
client_a
|
||||
.client()
|
||||
.request(proto::ShareAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
title: "Updated Title".to_string(),
|
||||
thread_data: b"updated data".to_vec(),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to re-share thread");
|
||||
|
||||
let get_response = client_b
|
||||
.client()
|
||||
.request(proto::GetSharedAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to get shared thread");
|
||||
|
||||
assert_eq!(get_response.title, "Updated Title");
|
||||
assert_eq!(get_response.thread_data, b"updated data".to_vec());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_get_nonexistent_thread(executor: BackgroundExecutor, cx: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client = server.create_client(cx, "user_a").await;
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let nonexistent_session_id = Uuid::new_v4().to_string();
|
||||
|
||||
let result = client
|
||||
.client()
|
||||
.request(proto::GetSharedAgentThread {
|
||||
session_id: nonexistent_session_id,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "Should fail for nonexistent thread");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_sync_imported_thread(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
executor.run_until_parked();
|
||||
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
// User A shares a thread with initial content.
|
||||
let initial_thread = SharedThread {
|
||||
title: "Initial Title".into(),
|
||||
messages: vec![],
|
||||
updated_at: chrono::Utc::now(),
|
||||
model: None,
|
||||
version: SharedThread::VERSION.to_string(),
|
||||
};
|
||||
|
||||
client_a
|
||||
.client()
|
||||
.request(proto::ShareAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
title: initial_thread.title.to_string(),
|
||||
thread_data: initial_thread.to_bytes().expect("Failed to serialize"),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to share thread");
|
||||
|
||||
// User B imports the thread.
|
||||
let initial_response = client_b
|
||||
.client()
|
||||
.request(proto::GetSharedAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to get shared thread");
|
||||
|
||||
let initial_imported =
|
||||
SharedThread::from_bytes(&initial_response.thread_data).expect("Failed to deserialize");
|
||||
assert_eq!(initial_imported.title.as_ref(), "Initial Title");
|
||||
|
||||
// User A updates the shared thread.
|
||||
let updated_thread = SharedThread {
|
||||
title: "Updated Title".into(),
|
||||
messages: vec![],
|
||||
updated_at: chrono::Utc::now(),
|
||||
model: None,
|
||||
version: SharedThread::VERSION.to_string(),
|
||||
};
|
||||
|
||||
client_a
|
||||
.client()
|
||||
.request(proto::ShareAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
title: updated_thread.title.to_string(),
|
||||
thread_data: updated_thread.to_bytes().expect("Failed to serialize"),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to re-share thread");
|
||||
|
||||
// User B syncs the imported thread (fetches the latest version).
|
||||
let synced_response = client_b
|
||||
.client()
|
||||
.request(proto::GetSharedAgentThread {
|
||||
session_id: session_id.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("Failed to sync shared thread");
|
||||
|
||||
let synced_thread =
|
||||
SharedThread::from_bytes(&synced_response.thread_data).expect("Failed to deserialize");
|
||||
|
||||
// The synced thread should have the updated title.
|
||||
assert_eq!(synced_thread.title.as_ref(), "Updated Title");
|
||||
}
|
||||
484
crates/collab/tests/integration/auto_watch_tests.rs
Normal file
484
crates/collab/tests/integration/auto_watch_tests.rs
Normal file
@@ -0,0 +1,484 @@
|
||||
use crate::TestServer;
|
||||
use call::ActiveCall;
|
||||
use client::ChannelId;
|
||||
use gpui::{App, BackgroundExecutor, Entity, TestAppContext, TestScreenCaptureSource};
|
||||
use project::Project;
|
||||
use rpc::proto::PeerId;
|
||||
use workspace::{AutoWatch, SharedScreen, Workspace};
|
||||
|
||||
use super::TestClient;
|
||||
|
||||
struct AutoWatchTestSetup {
|
||||
client_a: TestClient,
|
||||
client_b: TestClient,
|
||||
client_c: TestClient,
|
||||
channel_id: ChannelId,
|
||||
user_a_project: Entity<Project>,
|
||||
user_b_project: Entity<Project>,
|
||||
}
|
||||
|
||||
async fn setup_auto_watch_test(
|
||||
server: &mut TestServer,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) -> AutoWatchTestSetup {
|
||||
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, true).await
|
||||
}
|
||||
|
||||
async fn setup_auto_watch_late_joiner_test(
|
||||
server: &mut TestServer,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) -> AutoWatchTestSetup {
|
||||
setup_auto_watch_test_with_initial_participants(server, user_a, user_b, user_c, false).await
|
||||
}
|
||||
|
||||
async fn setup_auto_watch_test_with_initial_participants(
|
||||
server: &mut TestServer,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
join_user_c: bool,
|
||||
) -> AutoWatchTestSetup {
|
||||
let client_a = server.create_client(user_a, "user_a").await;
|
||||
let client_b = server.create_client(user_b, "user_b").await;
|
||||
let client_c = server.create_client(user_c, "user_c").await;
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, user_a),
|
||||
&mut [(&client_b, user_b), (&client_c, user_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let user_a_project = client_a.build_empty_local_project(false, user_a);
|
||||
let user_b_project = client_b.build_empty_local_project(false, user_b);
|
||||
|
||||
let active_call_a = user_a.read(ActiveCall::global);
|
||||
active_call_a
|
||||
.update(user_a, |call, cx| call.join_channel(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let active_call_b = user_b.read(ActiveCall::global);
|
||||
active_call_b
|
||||
.update(user_b, |call, cx| call.join_channel(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if join_user_c {
|
||||
let active_call_c = user_c.read(ActiveCall::global);
|
||||
active_call_c
|
||||
.update(user_c, |call, cx| call.join_channel(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
AutoWatchTestSetup {
|
||||
client_a,
|
||||
client_b,
|
||||
client_c,
|
||||
channel_id,
|
||||
user_a_project,
|
||||
user_b_project,
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_opens_existing_share_on_toggle(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
executor.run_until_parked();
|
||||
|
||||
start_screen_share(user_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_b.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_opens_share_when_no_one_is_sharing_yet(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
|
||||
start_screen_share(user_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_b.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_switches_to_next_share_on_share_end(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
|
||||
start_screen_share(user_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_b.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
start_screen_share(user_c).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
stop_screen_share(user_b);
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_c.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_ignores_shares_while_user_is_sharing(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
|
||||
start_screen_share(user_a).await;
|
||||
executor.run_until_parked();
|
||||
start_screen_share(user_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
// Should NOT open B's screen cause we are sharing
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_no_screen_share_tabs_exist(
|
||||
workspace,
|
||||
"should not open anyone's screen share when toggling on while sharing",
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_opens_share_after_local_user_stops_sharing(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
start_screen_share(user_a).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
start_screen_share(user_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
stop_screen_share(user_a);
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_b.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_toggle_off_leaves_tabs_open(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
start_screen_share(user_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_b.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_b.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_reopens_screen_share_from_returning_channel_participant(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_late_joiner_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
let (workspace_b, user_b) = setup
|
||||
.client_b
|
||||
.build_workspace(&setup.user_b_project, user_b);
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
workspace_b.update_in(user_b, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
executor.run_until_parked();
|
||||
|
||||
let active_call_c = user_c.read(ActiveCall::global);
|
||||
active_call_c
|
||||
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
executor.run_until_parked();
|
||||
|
||||
start_screen_share(user_c).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_c.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
workspace_b.update(user_b, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_c.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
active_call_c
|
||||
.update(user_c, |call, cx| call.hang_up(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_no_screen_share_tabs_exist(
|
||||
workspace,
|
||||
"user A should stop seeing user C's screen after user C hangs up",
|
||||
cx,
|
||||
);
|
||||
});
|
||||
workspace_b.update(user_b, |workspace, cx| {
|
||||
assert_no_screen_share_tabs_exist(
|
||||
workspace,
|
||||
"user B should stop seeing user C's screen after user C hangs up",
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
let active_call_c = user_c.read(ActiveCall::global);
|
||||
active_call_c
|
||||
.update(user_c, |call, cx| call.join_channel(setup.channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
executor.run_until_parked();
|
||||
|
||||
start_screen_share(user_c).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_c.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
workspace_b.update(user_b, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_c.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_auto_watch_is_disabled_when_following_collaborator(
|
||||
executor: BackgroundExecutor,
|
||||
user_a: &mut TestAppContext,
|
||||
user_b: &mut TestAppContext,
|
||||
user_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let setup = setup_auto_watch_test(&mut server, user_a, user_b, user_c).await;
|
||||
let (workspace_a, user_a) = setup
|
||||
.client_a
|
||||
.build_workspace(&setup.user_a_project, user_a);
|
||||
let user_b_peer_id = setup.client_b.peer_id().unwrap();
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.toggle_auto_watch(window, cx);
|
||||
});
|
||||
start_screen_share(user_b).await;
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, cx| {
|
||||
assert_active_item_is_screen_share_for_peer(
|
||||
workspace,
|
||||
setup.client_b.peer_id().unwrap(),
|
||||
cx,
|
||||
);
|
||||
});
|
||||
|
||||
workspace_a.update_in(user_a, |workspace, window, cx| {
|
||||
workspace.follow(user_b_peer_id, window, cx);
|
||||
});
|
||||
executor.run_until_parked();
|
||||
|
||||
workspace_a.update(user_a, |workspace, _cx| {
|
||||
assert_eq!(*workspace.auto_watch_state(), AutoWatch::Off);
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_no_screen_share_tabs_exist(workspace: &Workspace, message: &str, cx: &App) {
|
||||
let has_shared_screen_tab = workspace
|
||||
.active_pane()
|
||||
.read(cx)
|
||||
.items()
|
||||
.any(|item| item.downcast::<SharedScreen>().is_some());
|
||||
assert!(!has_shared_screen_tab, "{message}");
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_active_item_is_screen_share_for_peer(workspace: &Workspace, peer_id: PeerId, cx: &App) {
|
||||
let active_item = workspace.active_item(cx).expect("no active item");
|
||||
let shared_screen = active_item
|
||||
.downcast::<SharedScreen>()
|
||||
.expect("expected active item to be a shared screen");
|
||||
assert_eq!(shared_screen.read(cx).peer_id, peer_id);
|
||||
}
|
||||
|
||||
async fn start_screen_share(cx: &mut TestAppContext) {
|
||||
let display = TestScreenCaptureSource::new();
|
||||
cx.set_screen_capture_sources(vec![display]);
|
||||
let screen = cx
|
||||
.update(|cx| cx.screen_capture_sources())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap();
|
||||
let active_call = cx.read(ActiveCall::global);
|
||||
active_call
|
||||
.update(cx, |call, cx| {
|
||||
call.room()
|
||||
.unwrap()
|
||||
.update(cx, |room, cx| room.share_screen(screen, cx))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn stop_screen_share(cx: &mut TestAppContext) {
|
||||
let active_call = cx.read(ActiveCall::global);
|
||||
active_call
|
||||
.update(cx, |call, cx| {
|
||||
call.room()
|
||||
.unwrap()
|
||||
.update(cx, |room, cx| room.unshare_screen(true, cx))
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
884
crates/collab/tests/integration/channel_buffer_tests.rs
Normal file
884
crates/collab/tests/integration/channel_buffer_tests.rs
Normal file
@@ -0,0 +1,884 @@
|
||||
use crate::{TestServer, test_server::open_channel_notes};
|
||||
use call::ActiveCall;
|
||||
use channel::ACKNOWLEDGE_DEBOUNCE_INTERVAL;
|
||||
use client::{Collaborator, LegacyUserId, ParticipantIndex};
|
||||
use collab::rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT};
|
||||
|
||||
use collab_ui::channel_view::ChannelView;
|
||||
use collections::HashMap;
|
||||
use editor::{Anchor, Editor, MultiBufferOffset, ToOffset};
|
||||
use futures::future;
|
||||
use gpui::{BackgroundExecutor, Context, Entity, TestAppContext, Window};
|
||||
use rpc::{RECEIVE_TIMEOUT, proto::PeerId};
|
||||
use serde_json::json;
|
||||
use std::ops::Range;
|
||||
use util::rel_path::rel_path;
|
||||
use workspace::CollaboratorId;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_core_channel_buffers(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel("zed", None, (&client_a, cx_a), &mut [(&client_b, cx_b)])
|
||||
.await;
|
||||
|
||||
// Client A joins the channel buffer
|
||||
let channel_buffer_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A edits the buffer
|
||||
let buffer_a = channel_buffer_a.read_with(cx_a, |buffer, _| buffer.buffer());
|
||||
buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.edit([(0..0, "hello world")], None, cx)
|
||||
});
|
||||
buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.edit([(5..5, ", cruel")], None, cx)
|
||||
});
|
||||
buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.edit([(0..5, "goodbye")], None, cx)
|
||||
});
|
||||
buffer_a.update(cx_a, |buffer, cx| buffer.undo(cx));
|
||||
assert_eq!(buffer_text(&buffer_a, cx_a), "hello, cruel world");
|
||||
executor.run_until_parked();
|
||||
|
||||
// Client B joins the channel buffer
|
||||
let channel_buffer_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
channel_buffer_b.read_with(cx_b, |buffer, _| {
|
||||
assert_collaborators(
|
||||
buffer.collaborators(),
|
||||
&[client_a.user_id(), client_b.user_id()],
|
||||
);
|
||||
});
|
||||
|
||||
// Client B sees the correct text, and then edits it
|
||||
let buffer_b = channel_buffer_b.read_with(cx_b, |buffer, _| buffer.buffer());
|
||||
assert_eq!(
|
||||
buffer_b.read_with(cx_b, |buffer, _| buffer.remote_id()),
|
||||
buffer_a.read_with(cx_a, |buffer, _| buffer.remote_id())
|
||||
);
|
||||
assert_eq!(buffer_text(&buffer_b, cx_b), "hello, cruel world");
|
||||
buffer_b.update(cx_b, |buffer, cx| {
|
||||
buffer.edit([(7..12, "beautiful")], None, cx)
|
||||
});
|
||||
|
||||
// Both A and B see the new edit
|
||||
executor.run_until_parked();
|
||||
assert_eq!(buffer_text(&buffer_a, cx_a), "hello, beautiful world");
|
||||
assert_eq!(buffer_text(&buffer_b, cx_b), "hello, beautiful world");
|
||||
|
||||
// Client A closes the channel buffer.
|
||||
cx_a.update(|_| drop(channel_buffer_a));
|
||||
executor.run_until_parked();
|
||||
|
||||
// Client B sees that client A is gone from the channel buffer.
|
||||
channel_buffer_b.read_with(cx_b, |buffer, _| {
|
||||
assert_collaborators(buffer.collaborators(), &[client_b.user_id()]);
|
||||
});
|
||||
|
||||
// Client A rejoins the channel buffer
|
||||
let _channel_buffer_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
executor.run_until_parked();
|
||||
|
||||
// Sanity test, make sure we saw A rejoining
|
||||
channel_buffer_b.read_with(cx_b, |buffer, _| {
|
||||
assert_collaborators(
|
||||
buffer.collaborators(),
|
||||
&[client_a.user_id(), client_b.user_id()],
|
||||
);
|
||||
});
|
||||
|
||||
// Client A loses connection.
|
||||
server.forbid_connections();
|
||||
server.disconnect_client(client_a.peer_id().unwrap());
|
||||
executor.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
|
||||
// Client B observes A disconnect
|
||||
channel_buffer_b.read_with(cx_b, |buffer, _| {
|
||||
assert_collaborators(buffer.collaborators(), &[client_b.user_id()]);
|
||||
});
|
||||
|
||||
// TODO:
|
||||
// - Test synchronizing offline updates, what happens to A's channel buffer when A disconnects
|
||||
// - Test interaction with channel deletion while buffer is open
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_notes_participant_indices(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
let active_call_b = cx_b.read(ActiveCall::global);
|
||||
|
||||
cx_a.update(editor::init);
|
||||
cx_b.update(editor::init);
|
||||
cx_c.update(editor::init);
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
client_a
|
||||
.fs()
|
||||
.insert_tree("/root", json!({"file.txt": "123"}))
|
||||
.await;
|
||||
let (project_a, worktree_id_a) = client_a.build_local_project_with_trust("/root", cx_a).await;
|
||||
let project_b = client_b.build_empty_local_project(false, cx_b);
|
||||
let project_c = client_c.build_empty_local_project(false, cx_c);
|
||||
|
||||
let (workspace_a, mut cx_a) = client_a.build_workspace(&project_a, cx_a);
|
||||
let (workspace_b, mut cx_b) = client_b.build_workspace(&project_b, cx_b);
|
||||
let (workspace_c, cx_c) = client_c.build_workspace(&project_c, cx_c);
|
||||
|
||||
// Clients A, B, and C open the channel notes
|
||||
let channel_view_a = cx_a
|
||||
.update(|window, cx| ChannelView::open(channel_id, None, workspace_a.clone(), window, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_view_b = cx_b
|
||||
.update(|window, cx| ChannelView::open(channel_id, None, workspace_b.clone(), window, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_view_c = cx_c
|
||||
.update(|window, cx| ChannelView::open(channel_id, None, workspace_c.clone(), window, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Clients A, B, and C all insert and select some text
|
||||
channel_view_a.update_in(cx_a, |notes, window, cx| {
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
editor.insert("a", window, cx);
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(1)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
executor.run_until_parked();
|
||||
channel_view_b.update_in(cx_b, |notes, window, cx| {
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
editor.move_down(&Default::default(), window, cx);
|
||||
editor.insert("b", window, cx);
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges(vec![MultiBufferOffset(1)..MultiBufferOffset(2)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
executor.run_until_parked();
|
||||
channel_view_c.update_in(cx_c, |notes, window, cx| {
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
editor.move_down(&Default::default(), window, cx);
|
||||
editor.insert("c", window, cx);
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges(vec![MultiBufferOffset(2)..MultiBufferOffset(3)]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Client A sees clients B and C without assigned colors, because they aren't
|
||||
// in a call together.
|
||||
executor.run_until_parked();
|
||||
channel_view_a.update_in(cx_a, |notes, window, cx| {
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
assert_remote_selections(editor, &[(None, 1..2), (None, 2..3)], window, cx);
|
||||
});
|
||||
});
|
||||
|
||||
// Clients A and B join the same call.
|
||||
for (call, cx) in [(&active_call_a, &mut cx_a), (&active_call_b, &mut cx_b)] {
|
||||
call.update(*cx, |call, cx| call.join_channel(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Clients A and B see each other with two different assigned colors. Client C
|
||||
// still doesn't have a color.
|
||||
executor.run_until_parked();
|
||||
channel_view_a.update_in(cx_a, |notes, window, cx| {
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
assert_remote_selections(
|
||||
editor,
|
||||
&[(Some(ParticipantIndex(1)), 1..2), (None, 2..3)],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
channel_view_b.update_in(cx_b, |notes, window, cx| {
|
||||
notes.editor.update(cx, |editor, cx| {
|
||||
assert_remote_selections(
|
||||
editor,
|
||||
&[(Some(ParticipantIndex(0)), 0..1), (None, 2..3)],
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Client A shares a project, and client B joins.
|
||||
let project_id = active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let project_b = client_b.join_remote_project(project_id, cx_b).await;
|
||||
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
|
||||
|
||||
// Clients A and B open the same file.
|
||||
let editor_a = workspace_a
|
||||
.update_in(cx_a, |workspace, window, cx| {
|
||||
workspace.open_path(
|
||||
(worktree_id_a, rel_path("file.txt")),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
let editor_b = workspace_b
|
||||
.update_in(cx_b, |workspace, window, cx| {
|
||||
workspace.open_path(
|
||||
(worktree_id_a, rel_path("file.txt")),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.downcast::<Editor>()
|
||||
.unwrap();
|
||||
|
||||
editor_a.update_in(cx_a, |editor, window, cx| {
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges(vec![MultiBufferOffset(0)..MultiBufferOffset(1)]);
|
||||
});
|
||||
});
|
||||
editor_b.update_in(cx_b, |editor, window, cx| {
|
||||
editor.change_selections(Default::default(), window, cx, |selections| {
|
||||
selections.select_ranges(vec![MultiBufferOffset(2)..MultiBufferOffset(3)]);
|
||||
});
|
||||
});
|
||||
executor.run_until_parked();
|
||||
|
||||
// Clients A and B see each other with the same colors as in the channel notes.
|
||||
editor_a.update_in(cx_a, |editor, window, cx| {
|
||||
assert_remote_selections(editor, &[(Some(ParticipantIndex(1)), 2..3)], window, cx);
|
||||
});
|
||||
editor_b.update_in(cx_b, |editor, window, cx| {
|
||||
assert_remote_selections(editor, &[(Some(ParticipantIndex(0)), 0..1)], window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_remote_selections(
|
||||
editor: &mut Editor,
|
||||
expected_selections: &[(Option<ParticipantIndex>, Range<usize>)],
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Editor>,
|
||||
) {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
let hub = editor.collaboration_hub().unwrap();
|
||||
let collaborators = hub.collaborators(cx);
|
||||
let range = Anchor::Min..Anchor::Max;
|
||||
let remote_selections = snapshot
|
||||
.remote_selections_in_range(&range, hub, cx)
|
||||
.map(|s| {
|
||||
let CollaboratorId::PeerId(peer_id) = s.collaborator_id else {
|
||||
panic!("unexpected collaborator id");
|
||||
};
|
||||
let start = s.selection.start.to_offset(snapshot.buffer_snapshot());
|
||||
let end = s.selection.end.to_offset(snapshot.buffer_snapshot());
|
||||
let user_id = collaborators.get(&peer_id).unwrap().user_id;
|
||||
let participant_index = hub.user_participant_indices(cx).get(&user_id).copied();
|
||||
(participant_index, start.0..end.0)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
remote_selections, expected_selections,
|
||||
"incorrect remote selections"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_multiple_handles_to_channel_buffer(
|
||||
deterministic: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(deterministic.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel("the-channel", None, (&client_a, cx_a), &mut [])
|
||||
.await;
|
||||
|
||||
let channel_buffer_1 = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx));
|
||||
let channel_buffer_2 = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx));
|
||||
let channel_buffer_3 = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx));
|
||||
|
||||
// All concurrent tasks for opening a channel buffer return the same model handle.
|
||||
let (channel_buffer, channel_buffer_2, channel_buffer_3) =
|
||||
future::try_join3(channel_buffer_1, channel_buffer_2, channel_buffer_3)
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_buffer_entity_id = channel_buffer.entity_id();
|
||||
assert_eq!(channel_buffer, channel_buffer_2);
|
||||
assert_eq!(channel_buffer, channel_buffer_3);
|
||||
|
||||
channel_buffer.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..0, "hello")], None, cx);
|
||||
})
|
||||
});
|
||||
deterministic.run_until_parked();
|
||||
|
||||
cx_a.update(|_| {
|
||||
drop(channel_buffer);
|
||||
drop(channel_buffer_2);
|
||||
drop(channel_buffer_3);
|
||||
});
|
||||
deterministic.run_until_parked();
|
||||
|
||||
// The channel buffer can be reopened after dropping it.
|
||||
let channel_buffer = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(channel_buffer.entity_id(), channel_buffer_entity_id);
|
||||
channel_buffer.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, _| {
|
||||
assert_eq!(buffer.text(), "hello");
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_buffer_disconnect(
|
||||
deterministic: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(deterministic.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_buffer_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let channel_buffer_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
server.forbid_connections();
|
||||
server.disconnect_client(client_a.peer_id().unwrap());
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
assert_eq!(buffer.channel(cx).unwrap().name, "the-channel");
|
||||
assert!(!buffer.is_connected());
|
||||
});
|
||||
|
||||
deterministic.run_until_parked();
|
||||
|
||||
server.allow_connections();
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
|
||||
deterministic.run_until_parked();
|
||||
|
||||
client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |channel_store, _| {
|
||||
channel_store.remove_channel(channel_id)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
deterministic.run_until_parked();
|
||||
|
||||
// Channel buffer observed the deletion
|
||||
channel_buffer_b.update(cx_b, |buffer, cx| {
|
||||
assert!(buffer.channel(cx).is_none());
|
||||
assert!(!buffer.is_connected());
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_rejoin_channel_buffer(
|
||||
deterministic: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(deterministic.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_buffer_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_buffer_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..0, "1")], None, cx);
|
||||
})
|
||||
});
|
||||
deterministic.run_until_parked();
|
||||
|
||||
// Client A disconnects.
|
||||
server.forbid_connections();
|
||||
server.disconnect_client(client_a.peer_id().unwrap());
|
||||
|
||||
// Both clients make an edit.
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(1..1, "2")], None, cx);
|
||||
})
|
||||
});
|
||||
channel_buffer_b.update(cx_b, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..0, "0")], None, cx);
|
||||
})
|
||||
});
|
||||
|
||||
// Both clients see their own edit.
|
||||
deterministic.run_until_parked();
|
||||
channel_buffer_a.read_with(cx_a, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "12");
|
||||
});
|
||||
channel_buffer_b.read_with(cx_b, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "01");
|
||||
});
|
||||
|
||||
// Client A reconnects. Both clients see each other's edits, and see
|
||||
// the same collaborators.
|
||||
server.allow_connections();
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT);
|
||||
channel_buffer_a.read_with(cx_a, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "012");
|
||||
});
|
||||
channel_buffer_b.read_with(cx_b, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "012");
|
||||
});
|
||||
|
||||
channel_buffer_a.read_with(cx_a, |buffer_a, _| {
|
||||
channel_buffer_b.read_with(cx_b, |buffer_b, _| {
|
||||
assert_eq!(buffer_a.collaborators(), buffer_b.collaborators());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_buffers_and_server_restarts(
|
||||
deterministic: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_c: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(deterministic.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let client_c = server.create_client(cx_c, "user_c").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b), (&client_c, cx_c)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channel_buffer_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_buffer_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let _channel_buffer_c = client_c
|
||||
.channel_store()
|
||||
.update(cx_c, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..0, "1")], None, cx);
|
||||
})
|
||||
});
|
||||
deterministic.run_until_parked();
|
||||
|
||||
// Client C can't reconnect.
|
||||
client_c.override_establish_connection(|_, cx| cx.spawn(async |_| future::pending().await));
|
||||
|
||||
// Server stops.
|
||||
server.reset().await;
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT);
|
||||
|
||||
// While the server is down, both clients make an edit.
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(1..1, "2")], None, cx);
|
||||
})
|
||||
});
|
||||
channel_buffer_b.update(cx_b, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..0, "0")], None, cx);
|
||||
})
|
||||
});
|
||||
|
||||
// Server restarts.
|
||||
server.start().await.unwrap();
|
||||
deterministic.advance_clock(CLEANUP_TIMEOUT);
|
||||
|
||||
// Clients reconnects. Clients A and B see each other's edits, and see
|
||||
// that client C has disconnected.
|
||||
channel_buffer_a.read_with(cx_a, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "012");
|
||||
});
|
||||
channel_buffer_b.read_with(cx_b, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "012");
|
||||
});
|
||||
|
||||
channel_buffer_a.read_with(cx_a, |buffer_a, _| {
|
||||
channel_buffer_b.read_with(cx_b, |buffer_b, _| {
|
||||
assert_collaborators(
|
||||
buffer_a.collaborators(),
|
||||
&[client_a.user_id(), client_b.user_id()],
|
||||
);
|
||||
assert_eq!(buffer_a.collaborators(), buffer_b.collaborators());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_buffer_changes(
|
||||
deterministic: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let (server, client_a, client_b, channel_id) = TestServer::start2(cx_a, cx_b).await;
|
||||
let (_, cx_a) = client_a.build_test_workspace(cx_a).await;
|
||||
let (workspace_b, cx_b) = client_b.build_test_workspace(cx_b).await;
|
||||
let channel_store_b = client_b.channel_store().clone();
|
||||
|
||||
// Editing the channel notes should set them to dirty
|
||||
open_channel_notes(channel_id, cx_a).await.unwrap();
|
||||
cx_a.simulate_keystrokes("1");
|
||||
channel_store_b.read_with(cx_b, |channel_store, _| {
|
||||
assert!(channel_store.has_channel_buffer_changed(channel_id))
|
||||
});
|
||||
|
||||
// Opening the buffer should clear the changed flag.
|
||||
open_channel_notes(channel_id, cx_b).await.unwrap();
|
||||
channel_store_b.read_with(cx_b, |channel_store, _| {
|
||||
assert!(!channel_store.has_channel_buffer_changed(channel_id))
|
||||
});
|
||||
|
||||
// Editing the channel while the buffer is open should not show that the buffer has changed.
|
||||
cx_a.simulate_keystrokes("2");
|
||||
channel_store_b.read_with(cx_b, |channel_store, _| {
|
||||
assert!(!channel_store.has_channel_buffer_changed(channel_id))
|
||||
});
|
||||
|
||||
// Test that the server is tracking things correctly, and we retain our 'not changed'
|
||||
// state across a disconnect
|
||||
deterministic.advance_clock(ACKNOWLEDGE_DEBOUNCE_INTERVAL);
|
||||
server
|
||||
.simulate_long_connection_interruption(client_b.peer_id().unwrap(), deterministic.clone());
|
||||
|
||||
// Re-subscribe to channels after reconnection (simulates collab panel re-rendering)
|
||||
client_b.initialize_channel_store(cx_b);
|
||||
deterministic.run_until_parked();
|
||||
|
||||
channel_store_b.read_with(cx_b, |channel_store, _| {
|
||||
assert!(!channel_store.has_channel_buffer_changed(channel_id))
|
||||
});
|
||||
|
||||
// Closing the buffer should re-enable change tracking
|
||||
cx_b.update(|window, cx| {
|
||||
workspace_b.update(cx, |workspace, cx| {
|
||||
workspace.close_all_items_and_panes(&Default::default(), window, cx)
|
||||
});
|
||||
});
|
||||
deterministic.run_until_parked();
|
||||
|
||||
cx_a.simulate_keystrokes("3");
|
||||
channel_store_b.read_with(cx_b, |channel_store, _| {
|
||||
assert!(channel_store.has_channel_buffer_changed(channel_id))
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_buffer_changes_persist(
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
cx_b2: &mut TestAppContext,
|
||||
) {
|
||||
let (mut server, client_a, client_b, channel_id) = TestServer::start2(cx_a, cx_b).await;
|
||||
let (_, cx_a) = client_a.build_test_workspace(cx_a).await;
|
||||
let (_, cx_b) = client_b.build_test_workspace(cx_b).await;
|
||||
|
||||
// a) edits the notes
|
||||
open_channel_notes(channel_id, cx_a).await.unwrap();
|
||||
cx_a.simulate_keystrokes("1");
|
||||
// b) opens them to observe the current version
|
||||
open_channel_notes(channel_id, cx_b).await.unwrap();
|
||||
|
||||
// On boot the client should get the correct state.
|
||||
let client_b2 = server.create_client(cx_b2, "user_b").await;
|
||||
let channel_store_b2 = client_b2.channel_store().clone();
|
||||
channel_store_b2.read_with(cx_b2, |channel_store, _| {
|
||||
assert!(!channel_store.has_channel_buffer_changed(channel_id))
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_buffer_operations_lost_on_reconnect(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"the-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
|
||||
// Both clients open the channel buffer.
|
||||
let channel_buffer_a = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let channel_buffer_b = client_b
|
||||
.channel_store()
|
||||
.update(cx_b, |store, cx| store.open_channel_buffer(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Step 1: Client A makes an initial edit that syncs to B.
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(0..0, "a")], None, cx);
|
||||
})
|
||||
});
|
||||
executor.run_until_parked();
|
||||
|
||||
// Verify both clients see "a".
|
||||
channel_buffer_a.read_with(cx_a, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "a");
|
||||
});
|
||||
channel_buffer_b.read_with(cx_b, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "a");
|
||||
});
|
||||
|
||||
// Step 2: Disconnect client A. Do NOT advance past RECONNECT_TIMEOUT
|
||||
// so that the buffer stays in `opened_buffers` for rejoin.
|
||||
server.forbid_connections();
|
||||
server.disconnect_client(client_a.peer_id().unwrap());
|
||||
executor.run_until_parked();
|
||||
|
||||
// Step 3: While disconnected, client A makes an offline edit ("b").
|
||||
// on_buffer_update fires but client.send() fails because transport is down.
|
||||
channel_buffer_a.update(cx_a, |buffer, cx| {
|
||||
buffer.buffer().update(cx, |buffer, cx| {
|
||||
buffer.edit([(1..1, "b")], None, cx);
|
||||
})
|
||||
});
|
||||
executor.run_until_parked();
|
||||
|
||||
// Client A sees "ab" locally; B still sees "a".
|
||||
channel_buffer_a.read_with(cx_a, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "ab");
|
||||
});
|
||||
channel_buffer_b.read_with(cx_b, |buffer, cx| {
|
||||
assert_eq!(buffer.buffer().read(cx).text(), "a");
|
||||
});
|
||||
|
||||
// Step 4: Reconnect and make a racing edit in parallel.
|
||||
//
|
||||
// The race condition occurs when:
|
||||
// 1. Transport reconnects, handle_connect captures version V (with "b") and sends RejoinChannelBuffers
|
||||
// 2. DURING the async gap (awaiting response), user makes edit "c"
|
||||
// 3. on_buffer_update sends UpdateChannelBuffer (succeeds because transport is up)
|
||||
// 4. Server receives BOTH messages concurrently (FuturesUnordered)
|
||||
// 5. If UpdateChannelBuffer commits first, server version is inflated to include "c"
|
||||
// 6. RejoinChannelBuffers reads inflated version and sends it back
|
||||
// 7. Client's serialize_ops(inflated_version) filters out "b" (offline edit)
|
||||
// because the inflated version's timestamp covers "b"'s timestamp
|
||||
|
||||
// Get the buffer handle for spawning
|
||||
let buffer_for_edit = channel_buffer_a.read_with(cx_a, |buffer, _| buffer.buffer());
|
||||
|
||||
// Spawn the edit task - it will wait for executor to run it
|
||||
let edit_task = cx_a.spawn({
|
||||
let buffer = buffer_for_edit;
|
||||
async move |mut cx| {
|
||||
let _ = buffer.update(&mut cx, |buffer, cx| {
|
||||
buffer.edit([(2..2, "c")], None, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Allow connections so reconnect can succeed
|
||||
server.allow_connections();
|
||||
|
||||
// Advance clock to trigger reconnection attempt
|
||||
executor.advance_clock(RECEIVE_TIMEOUT);
|
||||
|
||||
// Run the edit task - this races with handle_connect
|
||||
edit_task.detach();
|
||||
|
||||
// Let everything settle.
|
||||
executor.run_until_parked();
|
||||
|
||||
// Step 7: Read final buffer text from both clients.
|
||||
let text_a = channel_buffer_a.read_with(cx_a, |buffer, cx| buffer.buffer().read(cx).text());
|
||||
let text_b = channel_buffer_b.read_with(cx_b, |buffer, cx| buffer.buffer().read(cx).text());
|
||||
|
||||
// Both clients must see the same text containing all three edits.
|
||||
assert_eq!(
|
||||
text_a, text_b,
|
||||
"Client A and B diverged! A sees {:?}, B sees {:?}. \
|
||||
Operations were lost during reconnection.",
|
||||
text_a, text_b
|
||||
);
|
||||
assert!(
|
||||
text_a.contains('a'),
|
||||
"Initial edit 'a' missing from final text {:?}",
|
||||
text_a
|
||||
);
|
||||
assert!(
|
||||
text_a.contains('b'),
|
||||
"Offline edit 'b' missing from final text {:?}. \
|
||||
This is the reconnection race bug: the offline operation was \
|
||||
filtered out by serialize_ops because the server_version was \
|
||||
inflated by a racing UpdateChannelBuffer.",
|
||||
text_a
|
||||
);
|
||||
assert!(
|
||||
text_a.contains('c'),
|
||||
"Racing edit 'c' missing from final text {:?}",
|
||||
text_a
|
||||
);
|
||||
|
||||
// Step 8: Verify the invariant directly — every operation known to
|
||||
// client A must be observed by client B's version. If any operation
|
||||
// in A's history is not covered by B's version, it was lost.
|
||||
channel_buffer_a.read_with(cx_a, |buf_a, cx_a_inner| {
|
||||
let buffer_a = buf_a.buffer().read(cx_a_inner);
|
||||
let ops_a = buffer_a.operations();
|
||||
channel_buffer_b.read_with(cx_b, |buf_b, cx_b_inner| {
|
||||
let buffer_b = buf_b.buffer().read(cx_b_inner);
|
||||
let version_b = buffer_b.version();
|
||||
for (lamport, _op) in ops_a.iter() {
|
||||
assert!(
|
||||
version_b.observed(*lamport),
|
||||
"Operation with lamport timestamp {:?} from client A \
|
||||
is NOT observed by client B's version. This operation \
|
||||
was lost during reconnection.",
|
||||
lamport
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_collaborators(
|
||||
collaborators: &HashMap<PeerId, Collaborator>,
|
||||
ids: &[Option<LegacyUserId>],
|
||||
) {
|
||||
let mut user_ids = collaborators
|
||||
.values()
|
||||
.map(|collaborator| collaborator.user_id)
|
||||
.collect::<Vec<_>>();
|
||||
user_ids.sort();
|
||||
assert_eq!(
|
||||
user_ids,
|
||||
ids.iter().map(|id| id.unwrap()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
fn buffer_text(channel_buffer: &Entity<language::Buffer>, cx: &mut TestAppContext) -> String {
|
||||
channel_buffer.read_with(cx, |buffer, _| buffer.text())
|
||||
}
|
||||
314
crates/collab/tests/integration/channel_guest_tests.rs
Normal file
314
crates/collab/tests/integration/channel_guest_tests.rs
Normal file
@@ -0,0 +1,314 @@
|
||||
use crate::TestServer;
|
||||
use call::ActiveCall;
|
||||
use collab::db::ChannelId;
|
||||
use editor::Editor;
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use rpc::proto;
|
||||
use util::rel_path::rel_path;
|
||||
#[gpui::test]
|
||||
async fn test_channel_guests(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
|
||||
let channel_id = server
|
||||
.make_public_channel("the-channel", &client_a, cx_a)
|
||||
.await;
|
||||
|
||||
// Client A shares a project in the channel
|
||||
let project_a = client_a.build_test_project(cx_a).await;
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| call.join_channel(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let project_id = active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.executor().run_until_parked();
|
||||
|
||||
// Client B joins channel A as a guest
|
||||
cx_b.update(|cx| {
|
||||
workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// b should be following a in the shared project.
|
||||
// B is a guest,
|
||||
executor.run_until_parked();
|
||||
|
||||
let active_call_b = cx_b.read(ActiveCall::global);
|
||||
let project_b =
|
||||
active_call_b.read_with(cx_b, |call, _| call.location().unwrap().upgrade().unwrap());
|
||||
let room_b = active_call_b.update(cx_b, |call, _| call.room().unwrap().clone());
|
||||
|
||||
assert_eq!(
|
||||
project_b.read_with(cx_b, |project, _| project.remote_id()),
|
||||
Some(project_id),
|
||||
);
|
||||
assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
|
||||
assert!(
|
||||
project_b
|
||||
.update(cx_b, |project, cx| {
|
||||
let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id();
|
||||
project.create_entry((worktree_id, rel_path("b.txt")), false, cx)
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(room_b.read_with(cx_b, |room, _| room.is_muted()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
|
||||
let channel_id = server
|
||||
.make_public_channel("the-channel", &client_a, cx_a)
|
||||
.await;
|
||||
|
||||
let project_a = client_a.build_test_project(cx_a).await;
|
||||
cx_a.update(|cx| {
|
||||
workspace::join_channel(channel_id, client_a.app_state.clone(), None, None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client A shares a project in the channel
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// Client B joins channel A as a guest
|
||||
cx_b.update(|cx| {
|
||||
workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// client B opens 1.txt as a guest
|
||||
let (workspace_b, cx_b) = client_b.active_workspace(cx_b);
|
||||
let room_b = cx_b
|
||||
.read(ActiveCall::global)
|
||||
.update(cx_b, |call, _| call.room().unwrap().clone());
|
||||
cx_b.simulate_keystrokes("cmd-p");
|
||||
cx_a.run_until_parked();
|
||||
cx_b.simulate_keystrokes("1 enter");
|
||||
|
||||
let (project_b, editor_b) = workspace_b.update(cx_b, |workspace, cx| {
|
||||
(
|
||||
workspace.project().clone(),
|
||||
workspace.active_item_as::<Editor>(cx).unwrap(),
|
||||
)
|
||||
});
|
||||
assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
|
||||
assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx)));
|
||||
cx_b.update(|_window, cx_b| {
|
||||
assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
|
||||
});
|
||||
assert!(
|
||||
room_b
|
||||
.update(cx_b, |room, cx| room.share_microphone(cx))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// B is promoted
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
call.room().unwrap().update(cx, |room, cx| {
|
||||
room.set_participant_role(
|
||||
client_b.user_id().unwrap(),
|
||||
proto::ChannelRole::Member,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// project and buffers are now editable
|
||||
assert!(project_b.read_with(cx_b, |project, cx| !project.is_read_only(cx)));
|
||||
assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx)));
|
||||
|
||||
// B sees themselves as muted, and can unmute.
|
||||
cx_b.update(|_window, cx_b| {
|
||||
assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
|
||||
});
|
||||
room_b.read_with(cx_b, |room, _| assert!(room.is_muted()));
|
||||
room_b.update(cx_b, |room, cx| room.toggle_mute(cx));
|
||||
cx_a.run_until_parked();
|
||||
room_b.read_with(cx_b, |room, _| assert!(!room.is_muted()));
|
||||
|
||||
// B is demoted
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
call.room().unwrap().update(cx, |room, cx| {
|
||||
room.set_participant_role(
|
||||
client_b.user_id().unwrap(),
|
||||
proto::ChannelRole::Guest,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
|
||||
// project and buffers are no longer editable
|
||||
assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx)));
|
||||
assert!(editor_b.update(cx_b, |editor, cx| editor.read_only(cx)));
|
||||
assert!(
|
||||
room_b
|
||||
.update(cx_b, |room, cx| room.share_microphone(cx))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
|
||||
let mut server = TestServer::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
let active_call_b = cx_b.read(ActiveCall::global);
|
||||
|
||||
// Create a parent channel that requires the Zed CLA
|
||||
let parent_channel_id = server
|
||||
.make_channel("the-channel", None, (&client_a, cx_a), &mut [])
|
||||
.await;
|
||||
server
|
||||
.app_state
|
||||
.db
|
||||
.set_channel_requires_zed_cla(ChannelId::from_proto(parent_channel_id.0), true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a public channel that is a child of the parent channel.
|
||||
let channel_id = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| {
|
||||
store.create_channel("the-sub-channel", Some(parent_channel_id), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| {
|
||||
store.set_channel_visibility(parent_channel_id, proto::ChannelVisibility::Public, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| {
|
||||
store.set_channel_visibility(channel_id, proto::ChannelVisibility::Public, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Users A and B join the channel. B is a guest.
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| call.join_channel(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
active_call_b
|
||||
.update(cx_b, |call, cx| call.join_channel(channel_id, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
let room_b = cx_b
|
||||
.read(ActiveCall::global)
|
||||
.update(cx_b, |call, _| call.room().unwrap().clone());
|
||||
cx_b.update(|cx_b| {
|
||||
assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
|
||||
});
|
||||
|
||||
// A tries to grant write access to B, but cannot because B has not
|
||||
// yet signed the zed CLA.
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
call.room().unwrap().update(cx, |room, cx| {
|
||||
room.set_participant_role(
|
||||
client_b.user_id().unwrap(),
|
||||
proto::ChannelRole::Member,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
cx_a.run_until_parked();
|
||||
assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects()));
|
||||
cx_b.update(|cx_b| {
|
||||
assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone()));
|
||||
});
|
||||
|
||||
// A tries to grant write access to B, but cannot because B has not
|
||||
// yet signed the zed CLA.
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
call.room().unwrap().update(cx, |room, cx| {
|
||||
room.set_participant_role(
|
||||
client_b.user_id().unwrap(),
|
||||
proto::ChannelRole::Talker,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects()));
|
||||
cx_b.update(|cx_b| {
|
||||
assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
|
||||
});
|
||||
|
||||
// User B signs the zed CLA.
|
||||
let user_b = server
|
||||
.app_state
|
||||
.user_service
|
||||
.get_user_by_github_login("user_b")
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("user_b not found");
|
||||
server
|
||||
.app_state
|
||||
.db
|
||||
.add_contributor(user_b.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// A can now grant write access to B.
|
||||
active_call_a
|
||||
.update(cx_a, |call, cx| {
|
||||
call.room().unwrap().update(cx, |room, cx| {
|
||||
room.set_participant_role(
|
||||
client_b.user_id().unwrap(),
|
||||
proto::ChannelRole::Member,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.run_until_parked();
|
||||
assert!(room_b.read_with(cx_b, |room, _| room.can_share_projects()));
|
||||
cx_b.update(|cx_b| {
|
||||
assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone()));
|
||||
});
|
||||
}
|
||||
1476
crates/collab/tests/integration/channel_tests.rs
Normal file
1476
crates/collab/tests/integration/channel_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
356
crates/collab/tests/integration/collab_panel_tests.rs
Normal file
356
crates/collab/tests/integration/collab_panel_tests.rs
Normal file
@@ -0,0 +1,356 @@
|
||||
use crate::TestServer;
|
||||
use collab_ui::CollabPanel;
|
||||
use collab_ui::collab_panel::{MoveChannelDown, MoveChannelUp, ToggleSelectedChannelFavorite};
|
||||
use gpui::TestAppContext;
|
||||
use menu::{SelectNext, SelectPrevious};
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_reorder_favorite_channels_independently_of_channels(cx: &mut TestAppContext) {
|
||||
let (server, client) = TestServer::start1(cx).await;
|
||||
let root = server
|
||||
.make_channel("root", None, (&client, cx), &mut [])
|
||||
.await;
|
||||
let _ = server
|
||||
.make_channel("channel-a", Some(root), (&client, cx), &mut [])
|
||||
.await;
|
||||
let _ = server
|
||||
.make_channel("channel-b", Some(root), (&client, cx), &mut [])
|
||||
.await;
|
||||
let _ = server
|
||||
.make_channel("channel-c", Some(root), (&client, cx), &mut [])
|
||||
.await;
|
||||
|
||||
let (workspace, cx) = client.build_test_workspace(cx).await;
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = CollabPanel::new(workspace, window, cx);
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify initial state.
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Select channel-b.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b <== selected",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Favorite channel-b.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-b",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b <== selected",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Select channel-c.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
});
|
||||
// Favorite channel-c.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c <== selected",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Navigate up to favorite channel-b .
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.select_previous(&SelectPrevious, window, cx);
|
||||
panel.select_previous(&SelectPrevious, window, cx);
|
||||
panel.select_previous(&SelectPrevious, window, cx);
|
||||
panel.select_previous(&SelectPrevious, window, cx);
|
||||
panel.select_previous(&SelectPrevious, window, cx);
|
||||
panel.select_previous(&SelectPrevious, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-b <== selected",
|
||||
" #️⃣ channel-c",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Move favorite channel-b down.
|
||||
// The Channels section should remain unchanged
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.move_channel_down(&MoveChannelDown, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-c",
|
||||
" #️⃣ channel-b <== selected",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Move favorite channel-b down again when it's already last (should be no-op).
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.move_channel_down(&MoveChannelDown, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-c",
|
||||
" #️⃣ channel-b <== selected",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Move favorite channel-b back up.
|
||||
// The Channels section should remain unchanged.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.move_channel_up(&MoveChannelUp, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-b <== selected",
|
||||
" #️⃣ channel-c",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Move favorite channel-b up again when it's already first (should be no-op).
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.move_channel_up(&MoveChannelUp, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-b <== selected",
|
||||
" #️⃣ channel-c",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Unfavorite channel-b.
|
||||
// Selection should move to the next favorite (channel-c).
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-c <== selected",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Unfavorite channel-c.
|
||||
// Favorites section should disappear entirely.
|
||||
// Selection should move to the next available item.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Channels]",
|
||||
" v root <== selected",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_reorder_channels_independently_of_favorites(cx: &mut TestAppContext) {
|
||||
let (server, client) = TestServer::start1(cx).await;
|
||||
let root = server
|
||||
.make_channel("root", None, (&client, cx), &mut [])
|
||||
.await;
|
||||
let _ = server
|
||||
.make_channel("channel-a", Some(root), (&client, cx), &mut [])
|
||||
.await;
|
||||
let _ = server
|
||||
.make_channel("channel-b", Some(root), (&client, cx), &mut [])
|
||||
.await;
|
||||
let _ = server
|
||||
.make_channel("channel-c", Some(root), (&client, cx), &mut [])
|
||||
.await;
|
||||
|
||||
let (workspace, cx) = client.build_test_workspace(cx).await;
|
||||
let panel = workspace.update_in(cx, |workspace, window, cx| {
|
||||
let panel = CollabPanel::new(workspace, window, cx);
|
||||
workspace.add_panel(panel.clone(), window, cx);
|
||||
panel
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Select channel-a.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a <== selected",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Favorite channel-a.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
|
||||
});
|
||||
|
||||
// Select channel-b.
|
||||
// Favorite channel-b.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.select_next(&SelectNext, window, cx);
|
||||
panel.toggle_selected_channel_favorite(&ToggleSelectedChannelFavorite, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b <== selected",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Select channel-a in the Channels section.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.select_previous(&SelectPrevious, window, cx);
|
||||
});
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-a <== selected",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
|
||||
// Move channel-a down.
|
||||
// The Favorites section should remain unchanged.
|
||||
// Selection should remain on channel-a in the Channels section,
|
||||
// not jump to channel-a in Favorites.
|
||||
panel.update_in(cx, |panel, window, cx| {
|
||||
panel.move_channel_down(&MoveChannelDown, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
panel.read_with(cx, |panel, _| panel.entries_as_strings()),
|
||||
&[
|
||||
"[Favorites]",
|
||||
" #️⃣ channel-a",
|
||||
" #️⃣ channel-b",
|
||||
"[Channels]",
|
||||
" v root",
|
||||
" #️⃣ channel-b",
|
||||
" #️⃣ channel-a <== selected",
|
||||
" #️⃣ channel-c",
|
||||
"[Contacts]",
|
||||
]
|
||||
);
|
||||
}
|
||||
54
crates/collab/tests/integration/collab_tests.rs
Normal file
54
crates/collab/tests/integration/collab_tests.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use call::Room;
|
||||
use client::ChannelId;
|
||||
use gpui::{Entity, TestAppContext};
|
||||
|
||||
mod agent_sharing_tests;
|
||||
mod auto_watch_tests;
|
||||
mod channel_buffer_tests;
|
||||
mod channel_guest_tests;
|
||||
mod channel_tests;
|
||||
mod collab_panel_tests;
|
||||
mod db_tests;
|
||||
mod editor_tests;
|
||||
mod following_tests;
|
||||
mod git_tests;
|
||||
mod integration_tests;
|
||||
mod notification_tests;
|
||||
mod random_channel_buffer_tests;
|
||||
mod random_project_collaboration_tests;
|
||||
mod randomized_test_helpers;
|
||||
mod remote_editing_collaboration_tests;
|
||||
mod test_server;
|
||||
|
||||
pub use randomized_test_helpers::{
|
||||
RandomizedTest, TestError, UserTestPlan, run_randomized_test, save_randomized_test_plan,
|
||||
};
|
||||
pub use test_server::{TestClient, TestServer};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct RoomParticipants {
|
||||
remote: Vec<String>,
|
||||
pending: Vec<String>,
|
||||
}
|
||||
|
||||
fn room_participants(room: &Entity<Room>, cx: &mut TestAppContext) -> RoomParticipants {
|
||||
room.read_with(cx, |room, _| {
|
||||
let mut remote = room
|
||||
.remote_participants()
|
||||
.values()
|
||||
.map(|participant| participant.user.github_login.clone().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let mut pending = room
|
||||
.pending_participants()
|
||||
.iter()
|
||||
.map(|user| user.github_login.clone().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
remote.sort();
|
||||
pending.sort();
|
||||
RoomParticipants { remote, pending }
|
||||
})
|
||||
}
|
||||
|
||||
fn channel_id(room: &Entity<Room>, cx: &mut TestAppContext) -> Option<ChannelId> {
|
||||
cx.read(|cx| room.read(cx).channel_id())
|
||||
}
|
||||
223
crates/collab/tests/integration/db_tests.rs
Normal file
223
crates/collab/tests/integration/db_tests.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
mod buffer_tests;
|
||||
mod channel_tests;
|
||||
mod db_tests;
|
||||
mod extension_tests;
|
||||
mod migrations;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI32, Ordering::SeqCst};
|
||||
use std::time::Duration;
|
||||
|
||||
use collections::HashSet;
|
||||
use gpui::BackgroundExecutor;
|
||||
use parking_lot::Mutex;
|
||||
use rand::prelude::*;
|
||||
use sea_orm::ConnectionTrait;
|
||||
use sqlx::migrate::MigrateDatabase;
|
||||
|
||||
use self::migrations::run_database_migrations;
|
||||
|
||||
use collab::db::*;
|
||||
|
||||
pub struct TestDb {
|
||||
pub db: Option<Arc<Database>>,
|
||||
pub connection: Option<sqlx::AnyConnection>,
|
||||
}
|
||||
|
||||
impl TestDb {
|
||||
pub fn sqlite(executor: BackgroundExecutor) -> Self {
|
||||
let url = "sqlite::memory:";
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let mut db = runtime.block_on(async {
|
||||
let mut options = ConnectOptions::new(url);
|
||||
options.max_connections(5);
|
||||
let mut db = Database::new(options).await.unwrap();
|
||||
let sql = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/migrations.sqlite/20221109000000_test_schema.sql"
|
||||
));
|
||||
db.pool
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
db.pool.get_database_backend(),
|
||||
sql,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
db.initialize_notification_kinds().await.unwrap();
|
||||
db
|
||||
});
|
||||
|
||||
db.test_options = Some(DatabaseTestOptions {
|
||||
executor,
|
||||
runtime,
|
||||
query_failure_probability: parking_lot::Mutex::new(0.0),
|
||||
});
|
||||
|
||||
Self {
|
||||
db: Some(Arc::new(db)),
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn postgres(executor: BackgroundExecutor) -> Self {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
let _guard = LOCK.lock();
|
||||
let mut rng = StdRng::from_os_rng();
|
||||
let url = format!(
|
||||
"postgres://postgres@localhost/zed-test-{}",
|
||||
rng.random::<u128>()
|
||||
);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let mut db = runtime.block_on(async {
|
||||
sqlx::Postgres::create_database(&url)
|
||||
.await
|
||||
.expect("failed to create test db");
|
||||
let mut options = ConnectOptions::new(url);
|
||||
options
|
||||
.max_connections(5)
|
||||
.idle_timeout(Duration::from_secs(0));
|
||||
let mut db = Database::new(options).await.unwrap();
|
||||
let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations");
|
||||
run_database_migrations(db.options(), migrations_path)
|
||||
.await
|
||||
.unwrap();
|
||||
db.initialize_notification_kinds().await.unwrap();
|
||||
db
|
||||
});
|
||||
|
||||
db.test_options = Some(DatabaseTestOptions {
|
||||
executor,
|
||||
runtime,
|
||||
query_failure_probability: parking_lot::Mutex::new(0.0),
|
||||
});
|
||||
|
||||
Self {
|
||||
db: Some(Arc::new(db)),
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn db(&self) -> &Arc<Database> {
|
||||
self.db.as_ref().unwrap()
|
||||
}
|
||||
|
||||
pub fn set_query_failure_probability(&self, probability: f64) {
|
||||
let database = self.db.as_ref().unwrap();
|
||||
let test_options = database.test_options.as_ref().unwrap();
|
||||
*test_options.query_failure_probability.lock() = probability;
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! test_both_dbs {
|
||||
($test_name:ident, $postgres_test_name:ident, $sqlite_test_name:ident) => {
|
||||
#[gpui::test]
|
||||
async fn $postgres_test_name(cx: &mut gpui::TestAppContext) {
|
||||
// In CI, only run postgres tests on Linux (where we have the postgres service).
|
||||
// Locally, always run them (assuming postgres is available).
|
||||
if std::env::var("CI").is_ok() && !cfg!(target_os = "linux") {
|
||||
return;
|
||||
}
|
||||
let test_db = $crate::db_tests::TestDb::postgres(cx.executor().clone());
|
||||
$test_name(test_db.db()).await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn $sqlite_test_name(cx: &mut gpui::TestAppContext) {
|
||||
let test_db = $crate::db_tests::TestDb::sqlite(cx.executor().clone());
|
||||
$test_name(test_db.db()).await;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl Drop for TestDb {
|
||||
fn drop(&mut self) {
|
||||
let db = self.db.take().unwrap();
|
||||
if let sea_orm::DatabaseBackend::Postgres = db.pool.get_database_backend() {
|
||||
db.test_options.as_ref().unwrap().runtime.block_on(async {
|
||||
use util::ResultExt;
|
||||
let query = "
|
||||
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE
|
||||
pg_stat_activity.datname = current_database() AND
|
||||
pid <> pg_backend_pid();
|
||||
";
|
||||
db.pool
|
||||
.execute(sea_orm::Statement::from_string(
|
||||
db.pool.get_database_backend(),
|
||||
query,
|
||||
))
|
||||
.await
|
||||
.log_err();
|
||||
sqlx::Postgres::drop_database(db.options.get_url())
|
||||
.await
|
||||
.log_err();
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_channel_tree_matches(actual: Vec<Channel>, expected: Vec<Channel>) {
|
||||
let expected_channels = expected.into_iter().collect::<HashSet<_>>();
|
||||
let actual_channels = actual.into_iter().collect::<HashSet<_>>();
|
||||
pretty_assertions::assert_eq!(expected_channels, actual_channels);
|
||||
}
|
||||
|
||||
fn channel_tree(channels: &[(ChannelId, &[ChannelId], &'static str)]) -> Vec<Channel> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut order_by_parent: HashMap<Vec<ChannelId>, i32> = HashMap::new();
|
||||
|
||||
for (id, parent_path, name) in channels {
|
||||
let parent_key = parent_path.to_vec();
|
||||
let order = if parent_key.is_empty() {
|
||||
1
|
||||
} else {
|
||||
*order_by_parent
|
||||
.entry(parent_key.clone())
|
||||
.and_modify(|e| *e += 1)
|
||||
.or_insert(1)
|
||||
};
|
||||
|
||||
result.push(Channel {
|
||||
id: *id,
|
||||
name: (*name).to_owned(),
|
||||
visibility: ChannelVisibility::Members,
|
||||
parent_path: parent_key,
|
||||
channel_order: order,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
static GITHUB_USER_ID: AtomicI32 = AtomicI32::new(5);
|
||||
|
||||
async fn new_test_user(db: &Arc<Database>, email: &str) -> UserId {
|
||||
db.create_user(
|
||||
email,
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: email[0..email.find('@').unwrap()].to_string(),
|
||||
github_user_id: GITHUB_USER_ID.fetch_add(1, SeqCst),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id
|
||||
}
|
||||
362
crates/collab/tests/integration/db_tests/buffer_tests.rs
Normal file
362
crates/collab/tests/integration/db_tests/buffer_tests.rs
Normal file
@@ -0,0 +1,362 @@
|
||||
use super::*;
|
||||
use crate::test_both_dbs;
|
||||
use language::proto::{self, serialize_version};
|
||||
use rpc::ConnectionId;
|
||||
use text::{Buffer, ReplicaId};
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_buffers,
|
||||
test_channel_buffers_postgres,
|
||||
test_channel_buffers_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_buffers(db: &Arc<Database>) {
|
||||
let a_id = db
|
||||
.create_user(
|
||||
"user_a@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user_a".into(),
|
||||
github_user_id: 101,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
let b_id = db
|
||||
.create_user(
|
||||
"user_b@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user_b".into(),
|
||||
github_user_id: 102,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
|
||||
// This user will not be a part of the channel
|
||||
let c_id = db
|
||||
.create_user(
|
||||
"user_c@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user_c".into(),
|
||||
github_user_id: 103,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
|
||||
let owner_id = db.create_server("production").await.unwrap().0 as u32;
|
||||
|
||||
let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
|
||||
|
||||
db.invite_channel_member(zed_id, b_id, a_id, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.respond_to_channel_invite(zed_id, b_id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let connection_id_a = ConnectionId { owner_id, id: 1 };
|
||||
let _ = db
|
||||
.join_channel_buffer(zed_id, a_id, connection_id_a)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut buffer_a = Buffer::new(
|
||||
ReplicaId::new(0),
|
||||
text::BufferId::new(1).unwrap(),
|
||||
"".to_string(),
|
||||
);
|
||||
let operations = vec![
|
||||
buffer_a.edit([(0..0, "hello world")]),
|
||||
buffer_a.edit([(5..5, ", cruel")]),
|
||||
buffer_a.edit([(0..5, "goodbye")]),
|
||||
buffer_a.undo().unwrap().1,
|
||||
];
|
||||
assert_eq!(buffer_a.text(), "hello, cruel world");
|
||||
|
||||
let operations = operations
|
||||
.into_iter()
|
||||
.map(|op| proto::serialize_operation(&language::Operation::Buffer(op)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
db.update_channel_buffer(zed_id, a_id, &operations)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let connection_id_b = ConnectionId { owner_id, id: 2 };
|
||||
let buffer_response_b = db
|
||||
.join_channel_buffer(zed_id, b_id, connection_id_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut buffer_b = Buffer::new(
|
||||
ReplicaId::new(0),
|
||||
text::BufferId::new(1).unwrap(),
|
||||
buffer_response_b.base_text,
|
||||
);
|
||||
buffer_b.apply_ops(buffer_response_b.operations.into_iter().map(|operation| {
|
||||
let operation = proto::deserialize_operation(operation).unwrap();
|
||||
if let language::Operation::Buffer(operation) = operation {
|
||||
operation
|
||||
} else {
|
||||
unreachable!()
|
||||
}
|
||||
}));
|
||||
|
||||
assert_eq!(buffer_b.text(), "hello, cruel world");
|
||||
|
||||
// Ensure that C fails to open the buffer
|
||||
assert!(
|
||||
db.join_channel_buffer(zed_id, c_id, ConnectionId { owner_id, id: 3 })
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Ensure that both collaborators have shown up
|
||||
assert_eq!(
|
||||
buffer_response_b.collaborators,
|
||||
&[
|
||||
rpc::proto::Collaborator {
|
||||
user_id: a_id.to_proto(),
|
||||
peer_id: Some(rpc::proto::PeerId { id: 1, owner_id }),
|
||||
replica_id: ReplicaId::FIRST_COLLAB_ID.as_u16() as u32,
|
||||
is_host: false,
|
||||
committer_name: None,
|
||||
committer_email: None,
|
||||
},
|
||||
rpc::proto::Collaborator {
|
||||
user_id: b_id.to_proto(),
|
||||
peer_id: Some(rpc::proto::PeerId { id: 2, owner_id }),
|
||||
replica_id: ReplicaId::FIRST_COLLAB_ID.as_u16() as u32 + 1,
|
||||
is_host: false,
|
||||
committer_name: None,
|
||||
committer_email: None,
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
// Ensure that get_channel_buffer_collaborators works
|
||||
let zed_collaborats = db.get_channel_buffer_collaborators(zed_id).await.unwrap();
|
||||
assert_eq!(zed_collaborats, &[a_id, b_id]);
|
||||
|
||||
let left_buffer = db
|
||||
.leave_channel_buffer(zed_id, connection_id_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(left_buffer.connections, &[connection_id_a],);
|
||||
|
||||
let cargo_id = db.create_root_channel("cargo", a_id).await.unwrap();
|
||||
let _ = db
|
||||
.join_channel_buffer(cargo_id, a_id, connection_id_a)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.leave_channel_buffers(connection_id_a).await.unwrap();
|
||||
|
||||
let zed_collaborators = db.get_channel_buffer_collaborators(zed_id).await.unwrap();
|
||||
let cargo_collaborators = db.get_channel_buffer_collaborators(cargo_id).await.unwrap();
|
||||
assert_eq!(zed_collaborators, &[]);
|
||||
assert_eq!(cargo_collaborators, &[]);
|
||||
|
||||
// When everyone has left the channel, the operations are collapsed into
|
||||
// a new base text.
|
||||
let buffer_response_b = db
|
||||
.join_channel_buffer(zed_id, b_id, connection_id_b)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(buffer_response_b.base_text, "hello, cruel world");
|
||||
assert_eq!(buffer_response_b.operations, &[]);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_channel_buffers_last_operations,
|
||||
test_channel_buffers_last_operations_postgres,
|
||||
test_channel_buffers_last_operations_sqlite
|
||||
);
|
||||
|
||||
async fn test_channel_buffers_last_operations(db: &Database) {
|
||||
let user_id = db
|
||||
.create_user(
|
||||
"user_a@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user_a".into(),
|
||||
github_user_id: 101,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
let observer_id = db
|
||||
.create_user(
|
||||
"user_b@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user_b".into(),
|
||||
github_user_id: 102,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
let owner_id = db.create_server("production").await.unwrap().0 as u32;
|
||||
let connection_id = ConnectionId {
|
||||
owner_id,
|
||||
id: user_id.0 as u32,
|
||||
};
|
||||
|
||||
let mut buffers = Vec::new();
|
||||
let mut text_buffers = Vec::new();
|
||||
for i in 0..3 {
|
||||
let channel = db
|
||||
.create_root_channel(&format!("channel-{i}"), user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
db.invite_channel_member(channel, observer_id, user_id, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(channel, observer_id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let res = db
|
||||
.join_channel_buffer(channel, user_id, connection_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
buffers.push(
|
||||
db.transaction(|tx| async move { db.get_channel_buffer(channel, &tx).await })
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
text_buffers.push(Buffer::new(
|
||||
ReplicaId::new(res.replica_id as u16),
|
||||
text::BufferId::new(1).unwrap(),
|
||||
"".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
update_buffer(
|
||||
buffers[0].channel_id,
|
||||
user_id,
|
||||
db,
|
||||
vec![
|
||||
text_buffers[0].edit([(0..0, "a")]),
|
||||
text_buffers[0].edit([(0..0, "b")]),
|
||||
text_buffers[0].edit([(0..0, "c")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
update_buffer(
|
||||
buffers[1].channel_id,
|
||||
user_id,
|
||||
db,
|
||||
vec![
|
||||
text_buffers[1].edit([(0..0, "d")]),
|
||||
text_buffers[1].edit([(1..1, "e")]),
|
||||
text_buffers[1].edit([(2..2, "f")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
// cause buffer 1's epoch to increment.
|
||||
db.leave_channel_buffer(buffers[1].channel_id, connection_id)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_channel_buffer(buffers[1].channel_id, user_id, connection_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let replica_id = text_buffers[1].replica_id();
|
||||
text_buffers[1] = Buffer::new(
|
||||
replica_id,
|
||||
text::BufferId::new(1).unwrap(),
|
||||
"def".to_string(),
|
||||
);
|
||||
update_buffer(
|
||||
buffers[1].channel_id,
|
||||
user_id,
|
||||
db,
|
||||
vec![
|
||||
text_buffers[1].edit([(0..0, "g")]),
|
||||
text_buffers[1].edit([(0..0, "h")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
update_buffer(
|
||||
buffers[2].channel_id,
|
||||
user_id,
|
||||
db,
|
||||
vec![text_buffers[2].edit([(0..0, "i")])],
|
||||
)
|
||||
.await;
|
||||
|
||||
let channels_for_user = db.get_channels_for_user(user_id).await.unwrap();
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
channels_for_user.latest_buffer_versions,
|
||||
[
|
||||
rpc::proto::ChannelBufferVersion {
|
||||
channel_id: buffers[0].channel_id.to_proto(),
|
||||
epoch: 0,
|
||||
version: serialize_version(&text_buffers[0].version())
|
||||
.into_iter()
|
||||
.filter(
|
||||
|vector| vector.replica_id == text_buffers[0].replica_id().as_u16() as u32
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
},
|
||||
rpc::proto::ChannelBufferVersion {
|
||||
channel_id: buffers[1].channel_id.to_proto(),
|
||||
epoch: 1,
|
||||
version: serialize_version(&text_buffers[1].version())
|
||||
.into_iter()
|
||||
.filter(
|
||||
|vector| vector.replica_id == text_buffers[1].replica_id().as_u16() as u32
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
},
|
||||
rpc::proto::ChannelBufferVersion {
|
||||
channel_id: buffers[2].channel_id.to_proto(),
|
||||
epoch: 0,
|
||||
version: serialize_version(&text_buffers[2].version())
|
||||
.into_iter()
|
||||
.filter(
|
||||
|vector| vector.replica_id == text_buffers[2].replica_id().as_u16() as u32
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async fn update_buffer(
|
||||
channel_id: ChannelId,
|
||||
user_id: UserId,
|
||||
db: &Database,
|
||||
operations: Vec<text::Operation>,
|
||||
) {
|
||||
let operations = operations
|
||||
.into_iter()
|
||||
.map(|op| proto::serialize_operation(&language::Operation::Buffer(op)))
|
||||
.collect::<Vec<_>>();
|
||||
db.update_channel_buffer(channel_id, user_id, &operations)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
1041
crates/collab/tests/integration/db_tests/channel_tests.rs
Normal file
1041
crates/collab/tests/integration/db_tests/channel_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
383
crates/collab/tests/integration/db_tests/db_tests.rs
Normal file
383
crates/collab/tests/integration/db_tests/db_tests.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use crate::test_both_dbs;
|
||||
|
||||
use super::*;
|
||||
use collab::db::RoomId;
|
||||
use collab::db::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rpc::ConnectionId;
|
||||
use std::sync::Arc;
|
||||
|
||||
test_both_dbs!(
|
||||
test_add_contacts,
|
||||
test_add_contacts_postgres,
|
||||
test_add_contacts_sqlite
|
||||
);
|
||||
|
||||
async fn test_add_contacts(db: &Arc<Database>) {
|
||||
let mut user_ids = Vec::new();
|
||||
for i in 0..3 {
|
||||
user_ids.push(
|
||||
db.create_user(
|
||||
&format!("user{i}@example.com"),
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: format!("user{i}"),
|
||||
github_user_id: i,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id,
|
||||
);
|
||||
}
|
||||
|
||||
let user_1 = user_ids[0];
|
||||
let user_2 = user_ids[1];
|
||||
let user_3 = user_ids[2];
|
||||
|
||||
// User starts with no contacts
|
||||
assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]);
|
||||
|
||||
// User requests a contact. Both users see the pending request.
|
||||
db.send_contact_request(user_1, user_2).await.unwrap();
|
||||
assert!(!db.has_contact(user_1, user_2).await.unwrap());
|
||||
assert!(!db.has_contact(user_2, user_1).await.unwrap());
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[Contact::Outgoing { user_id: user_2 }],
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Incoming { user_id: user_1 }]
|
||||
);
|
||||
|
||||
// User 2 dismisses the contact request notification without accepting or rejecting.
|
||||
// We shouldn't notify them again.
|
||||
db.dismiss_contact_notification(user_1, user_2)
|
||||
.await
|
||||
.unwrap_err();
|
||||
db.dismiss_contact_notification(user_2, user_1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Incoming { user_id: user_1 }]
|
||||
);
|
||||
|
||||
// User can't accept their own contact request
|
||||
db.respond_to_contact_request(user_1, user_2, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
// User accepts a contact request. Both users see the contact.
|
||||
db.respond_to_contact_request(user_2, user_1, true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
busy: false,
|
||||
}],
|
||||
);
|
||||
assert!(db.has_contact(user_1, user_2).await.unwrap());
|
||||
assert!(db.has_contact(user_2, user_1).await.unwrap());
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
|
||||
// Users cannot re-request existing contacts.
|
||||
db.send_contact_request(user_1, user_2).await.unwrap_err();
|
||||
db.send_contact_request(user_2, user_1).await.unwrap_err();
|
||||
|
||||
// Users can't dismiss notifications of them accepting other users' requests.
|
||||
db.dismiss_contact_notification(user_2, user_1)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
|
||||
// Users can dismiss notifications of other users accepting their requests.
|
||||
db.dismiss_contact_notification(user_1, user_2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_2,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
|
||||
// Users send each other concurrent contact requests and
|
||||
// see that they are immediately accepted.
|
||||
db.send_contact_request(user_1, user_3).await.unwrap();
|
||||
db.send_contact_request(user_3, user_1).await.unwrap();
|
||||
assert_eq!(
|
||||
db.get_contacts(user_1).await.unwrap(),
|
||||
&[
|
||||
Contact::Accepted {
|
||||
user_id: user_2,
|
||||
busy: false,
|
||||
},
|
||||
Contact::Accepted {
|
||||
user_id: user_3,
|
||||
busy: false,
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user_3).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
busy: false,
|
||||
}],
|
||||
);
|
||||
|
||||
// User declines a contact request. Both users see that it is gone.
|
||||
db.send_contact_request(user_2, user_3).await.unwrap();
|
||||
db.respond_to_contact_request(user_3, user_2, false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!db.has_contact(user_2, user_3).await.unwrap());
|
||||
assert!(!db.has_contact(user_3, user_2).await.unwrap());
|
||||
assert_eq!(
|
||||
db.get_contacts(user_2).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
busy: false,
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_contacts(user_3).await.unwrap(),
|
||||
&[Contact::Accepted {
|
||||
user_id: user_1,
|
||||
busy: false,
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_project_count,
|
||||
test_project_count_postgres,
|
||||
test_project_count_sqlite
|
||||
);
|
||||
|
||||
async fn test_project_count(db: &Arc<Database>) {
|
||||
let owner_id = db.create_server("test").await.unwrap().0 as u32;
|
||||
|
||||
let user1 = db
|
||||
.create_user(
|
||||
"admin@example.com",
|
||||
None,
|
||||
true,
|
||||
NewUserParams {
|
||||
github_login: "admin".into(),
|
||||
github_user_id: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user2 = db
|
||||
.create_user(
|
||||
"user@example.com",
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: "user".into(),
|
||||
github_user_id: 1,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let room_id = RoomId::from_proto(
|
||||
db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "")
|
||||
.await
|
||||
.unwrap()
|
||||
.id,
|
||||
);
|
||||
db.call(
|
||||
room_id,
|
||||
user1.user_id,
|
||||
ConnectionId { owner_id, id: 0 },
|
||||
user2.user_id,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
|
||||
|
||||
db.share_project(
|
||||
room_id,
|
||||
ConnectionId { owner_id, id: 1 },
|
||||
&[],
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 1);
|
||||
|
||||
db.share_project(
|
||||
room_id,
|
||||
ConnectionId { owner_id, id: 1 },
|
||||
&[],
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);
|
||||
|
||||
// Projects shared by admins aren't counted.
|
||||
db.share_project(
|
||||
room_id,
|
||||
ConnectionId { owner_id, id: 0 },
|
||||
&[],
|
||||
false,
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);
|
||||
|
||||
db.leave_room(ConnectionId { owner_id, id: 1 })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_upsert_shared_thread,
|
||||
test_upsert_shared_thread_postgres,
|
||||
test_upsert_shared_thread_sqlite
|
||||
);
|
||||
|
||||
async fn test_upsert_shared_thread(db: &Arc<Database>) {
|
||||
use collab::db::SharedThreadId;
|
||||
use uuid::Uuid;
|
||||
|
||||
let user_id = new_test_user(db, "user1@example.com").await;
|
||||
|
||||
let thread_id = SharedThreadId(Uuid::new_v4());
|
||||
let title = "My Test Thread";
|
||||
let data = b"test thread data".to_vec();
|
||||
|
||||
db.upsert_shared_thread(thread_id, user_id, title, data.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = db.get_shared_thread(thread_id).await.unwrap();
|
||||
assert!(result.is_some(), "Should find the shared thread");
|
||||
|
||||
let (thread, username) = result.unwrap();
|
||||
assert_eq!(thread.title, title);
|
||||
assert_eq!(thread.data, data);
|
||||
assert_eq!(thread.user_id, user_id);
|
||||
assert_eq!(username, "user1");
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_upsert_shared_thread_updates_existing,
|
||||
test_upsert_shared_thread_updates_existing_postgres,
|
||||
test_upsert_shared_thread_updates_existing_sqlite
|
||||
);
|
||||
|
||||
async fn test_upsert_shared_thread_updates_existing(db: &Arc<Database>) {
|
||||
use collab::db::SharedThreadId;
|
||||
use uuid::Uuid;
|
||||
|
||||
let user_id = new_test_user(db, "user1@example.com").await;
|
||||
|
||||
let thread_id = SharedThreadId(Uuid::new_v4());
|
||||
|
||||
// Create initial thread.
|
||||
db.upsert_shared_thread(
|
||||
thread_id,
|
||||
user_id,
|
||||
"Original Title",
|
||||
b"original data".to_vec(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Update the same thread.
|
||||
db.upsert_shared_thread(
|
||||
thread_id,
|
||||
user_id,
|
||||
"Updated Title",
|
||||
b"updated data".to_vec(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = db.get_shared_thread(thread_id).await.unwrap();
|
||||
let (thread, _) = result.unwrap();
|
||||
|
||||
assert_eq!(thread.title, "Updated Title");
|
||||
assert_eq!(thread.data, b"updated data".to_vec());
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_cannot_update_another_users_shared_thread,
|
||||
test_cannot_update_another_users_shared_thread_postgres,
|
||||
test_cannot_update_another_users_shared_thread_sqlite
|
||||
);
|
||||
|
||||
async fn test_cannot_update_another_users_shared_thread(db: &Arc<Database>) {
|
||||
use collab::db::SharedThreadId;
|
||||
use uuid::Uuid;
|
||||
|
||||
let user1_id = new_test_user(db, "user1@example.com").await;
|
||||
let user2_id = new_test_user(db, "user2@example.com").await;
|
||||
|
||||
let thread_id = SharedThreadId(Uuid::new_v4());
|
||||
|
||||
db.upsert_shared_thread(thread_id, user1_id, "User 1 Thread", b"user1 data".to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = db
|
||||
.upsert_shared_thread(thread_id, user2_id, "User 2 Title", b"user2 data".to_vec())
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should not allow updating another user's thread"
|
||||
);
|
||||
}
|
||||
|
||||
test_both_dbs!(
|
||||
test_get_nonexistent_shared_thread,
|
||||
test_get_nonexistent_shared_thread_postgres,
|
||||
test_get_nonexistent_shared_thread_sqlite
|
||||
);
|
||||
|
||||
async fn test_get_nonexistent_shared_thread(db: &Arc<Database>) {
|
||||
use collab::db::SharedThreadId;
|
||||
use uuid::Uuid;
|
||||
|
||||
let result = db
|
||||
.get_shared_thread(SharedThreadId(Uuid::new_v4()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none(), "Should not find non-existent thread");
|
||||
}
|
||||
131
crates/collab/tests/integration/db_tests/extension_tests.rs
Normal file
131
crates/collab/tests/integration/db_tests/extension_tests.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
|
||||
use collab::db::Database;
|
||||
use collab::db::ExtensionVersionConstraints;
|
||||
use collab::db::{NewExtensionVersion, queries::extensions::convert_time_to_chrono};
|
||||
|
||||
use crate::test_both_dbs;
|
||||
|
||||
test_both_dbs!(
|
||||
test_extensions_by_id,
|
||||
test_extensions_by_id_postgres,
|
||||
test_extensions_by_id_sqlite
|
||||
);
|
||||
|
||||
async fn test_extensions_by_id(db: &Arc<Database>) {
|
||||
let versions = db.get_known_extension_versions().await.unwrap();
|
||||
assert!(versions.is_empty());
|
||||
|
||||
let t0 = time::OffsetDateTime::from_unix_timestamp_nanos(0).unwrap();
|
||||
let t0 = time::PrimitiveDateTime::new(t0.date(), t0.time());
|
||||
|
||||
let t0_chrono = convert_time_to_chrono(t0);
|
||||
|
||||
db.insert_extension_versions(
|
||||
&[
|
||||
(
|
||||
"ext1",
|
||||
vec![
|
||||
NewExtensionVersion {
|
||||
name: "Extension 1".into(),
|
||||
version: semver::Version::parse("0.0.1").unwrap(),
|
||||
description: "an extension".into(),
|
||||
authors: vec!["max".into()],
|
||||
repository: "ext1/repo".into(),
|
||||
schema_version: 1,
|
||||
wasm_api_version: Some("0.0.4".into()),
|
||||
provides: BTreeSet::from_iter([
|
||||
ExtensionProvides::Grammars,
|
||||
ExtensionProvides::Languages,
|
||||
]),
|
||||
published_at: t0,
|
||||
},
|
||||
NewExtensionVersion {
|
||||
name: "Extension 1".into(),
|
||||
version: semver::Version::parse("0.0.2").unwrap(),
|
||||
description: "a good extension".into(),
|
||||
authors: vec!["max".into()],
|
||||
repository: "ext1/repo".into(),
|
||||
schema_version: 1,
|
||||
wasm_api_version: Some("0.0.4".into()),
|
||||
provides: BTreeSet::from_iter([
|
||||
ExtensionProvides::Grammars,
|
||||
ExtensionProvides::Languages,
|
||||
ExtensionProvides::LanguageServers,
|
||||
]),
|
||||
published_at: t0,
|
||||
},
|
||||
NewExtensionVersion {
|
||||
name: "Extension 1".into(),
|
||||
version: semver::Version::parse("0.0.3").unwrap(),
|
||||
description: "a real good extension".into(),
|
||||
authors: vec!["max".into(), "marshall".into()],
|
||||
repository: "ext1/repo".into(),
|
||||
schema_version: 1,
|
||||
wasm_api_version: Some("0.0.5".into()),
|
||||
provides: BTreeSet::from_iter([
|
||||
ExtensionProvides::Grammars,
|
||||
ExtensionProvides::Languages,
|
||||
ExtensionProvides::LanguageServers,
|
||||
]),
|
||||
published_at: t0,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"ext2",
|
||||
vec![NewExtensionVersion {
|
||||
name: "Extension 2".into(),
|
||||
version: semver::Version::parse("0.2.0").unwrap(),
|
||||
description: "a great extension".into(),
|
||||
authors: vec!["marshall".into()],
|
||||
repository: "ext2/repo".into(),
|
||||
schema_version: 0,
|
||||
wasm_api_version: None,
|
||||
provides: BTreeSet::default(),
|
||||
published_at: t0,
|
||||
}],
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let extensions = db
|
||||
.get_extensions_by_ids(
|
||||
&["ext1"],
|
||||
Some(&ExtensionVersionConstraints {
|
||||
schema_versions: 1..=1,
|
||||
wasm_api_versions: "0.0.1".parse().unwrap()..="0.0.4".parse().unwrap(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
extensions,
|
||||
&[ExtensionMetadata {
|
||||
id: "ext1".into(),
|
||||
manifest: cloud_api_types::ExtensionApiManifest {
|
||||
name: "Extension 1".into(),
|
||||
version: "0.0.2".into(),
|
||||
authors: vec!["max".into()],
|
||||
description: Some("a good extension".into()),
|
||||
repository: "ext1/repo".into(),
|
||||
schema_version: Some(1),
|
||||
wasm_api_version: Some("0.0.4".into()),
|
||||
provides: BTreeSet::from_iter([
|
||||
ExtensionProvides::Grammars,
|
||||
ExtensionProvides::Languages,
|
||||
ExtensionProvides::LanguageServers,
|
||||
]),
|
||||
},
|
||||
published_at: t0_chrono,
|
||||
download_count: 0,
|
||||
}]
|
||||
);
|
||||
}
|
||||
47
crates/collab/tests/integration/db_tests/migrations.rs
Normal file
47
crates/collab/tests/integration/db_tests/migrations.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use collections::HashMap;
|
||||
use sea_orm::ConnectOptions;
|
||||
use sqlx::Connection;
|
||||
use sqlx::migrate::{Migrate, Migration, MigrationSource};
|
||||
|
||||
/// Runs the database migrations for the specified database.
|
||||
pub async fn run_database_migrations(
|
||||
database_options: &ConnectOptions,
|
||||
migrations_path: impl AsRef<Path>,
|
||||
) -> Result<Vec<(Migration, Duration)>> {
|
||||
let migrations = MigrationSource::resolve(migrations_path.as_ref())
|
||||
.await
|
||||
.map_err(|err| anyhow!("failed to load migrations: {err:?}"))?;
|
||||
|
||||
let mut connection = sqlx::AnyConnection::connect(database_options.get_url()).await?;
|
||||
|
||||
connection.ensure_migrations_table().await?;
|
||||
let applied_migrations: HashMap<_, _> = connection
|
||||
.list_applied_migrations()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|migration| (migration.version, migration))
|
||||
.collect();
|
||||
|
||||
let mut new_migrations = Vec::new();
|
||||
for migration in migrations {
|
||||
match applied_migrations.get(&migration.version) {
|
||||
Some(applied_migration) => {
|
||||
anyhow::ensure!(
|
||||
migration.checksum == applied_migration.checksum,
|
||||
"checksum mismatch for applied migration {}",
|
||||
migration.description
|
||||
);
|
||||
}
|
||||
None => {
|
||||
let elapsed = connection.apply(&migration).await?;
|
||||
new_migrations.push((migration, elapsed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(new_migrations)
|
||||
}
|
||||
5757
crates/collab/tests/integration/editor_tests.rs
Normal file
5757
crates/collab/tests/integration/editor_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
2486
crates/collab/tests/integration/following_tests.rs
Normal file
2486
crates/collab/tests/integration/following_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
1407
crates/collab/tests/integration/git_tests.rs
Normal file
1407
crates/collab/tests/integration/git_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
7302
crates/collab/tests/integration/integration_tests.rs
Normal file
7302
crates/collab/tests/integration/integration_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
164
crates/collab/tests/integration/notification_tests.rs
Normal file
164
crates/collab/tests/integration/notification_tests.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{BackgroundExecutor, TestAppContext};
|
||||
use notifications::NotificationEvent;
|
||||
use parking_lot::Mutex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rpc::{Notification, proto};
|
||||
|
||||
use crate::TestServer;
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_notifications(
|
||||
executor: BackgroundExecutor,
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
|
||||
// Wait for authentication/connection to Collab to be established.
|
||||
executor.run_until_parked();
|
||||
|
||||
let notification_events_a = Arc::new(Mutex::new(Vec::new()));
|
||||
let notification_events_b = Arc::new(Mutex::new(Vec::new()));
|
||||
client_a.notification_store().update(cx_a, |_, cx| {
|
||||
let events = notification_events_a.clone();
|
||||
cx.subscribe(&cx.entity(), move |_, _, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
client_b.notification_store().update(cx_b, |_, cx| {
|
||||
let events = notification_events_b.clone();
|
||||
cx.subscribe(&cx.entity(), move |_, _, event, _| {
|
||||
events.lock().push(event.clone());
|
||||
})
|
||||
.detach()
|
||||
});
|
||||
|
||||
// Client A sends a contact request to client B.
|
||||
client_a
|
||||
.user_store()
|
||||
.update(cx_a, |store, cx| store.request_contact(client_b.id(), cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client B receives a contact request notification and responds to the
|
||||
// request, accepting it.
|
||||
executor.run_until_parked();
|
||||
client_b.notification_store().update(cx_b, |store, cx| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ContactRequest {
|
||||
sender_id: client_a.id()
|
||||
}
|
||||
);
|
||||
assert!(!entry.is_read);
|
||||
assert_eq!(
|
||||
¬ification_events_b.lock()[0..],
|
||||
&[
|
||||
NotificationEvent::NewNotification {
|
||||
entry: entry.clone(),
|
||||
},
|
||||
NotificationEvent::NotificationsUpdated {
|
||||
old_range: 0..0,
|
||||
new_count: 1
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
store.respond_to_notification(entry.notification.clone(), true, cx);
|
||||
});
|
||||
|
||||
// Client B sees the notification is now read, and that they responded.
|
||||
executor.run_until_parked();
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
assert_eq!(store.unread_notification_count(), 0);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert!(entry.is_read);
|
||||
assert_eq!(entry.response, Some(true));
|
||||
assert_eq!(
|
||||
¬ification_events_b.lock()[2..],
|
||||
&[
|
||||
NotificationEvent::NotificationRead {
|
||||
entry: entry.clone(),
|
||||
},
|
||||
NotificationEvent::NotificationsUpdated {
|
||||
old_range: 0..1,
|
||||
new_count: 1
|
||||
}
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// Client A receives a notification that client B accepted their request.
|
||||
client_a.notification_store().read_with(cx_a, |store, _| {
|
||||
assert_eq!(store.notification_count(), 1);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ContactRequestAccepted {
|
||||
responder_id: client_b.id()
|
||||
}
|
||||
);
|
||||
assert!(!entry.is_read);
|
||||
});
|
||||
|
||||
// Client A creates a channel and invites client B to be a member.
|
||||
let channel_id = client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| {
|
||||
store.create_channel("the-channel", None, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
client_a
|
||||
.channel_store()
|
||||
.update(cx_a, |store, cx| {
|
||||
store.invite_member(channel_id, client_b.id(), proto::ChannelRole::Member, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Client B receives a channel invitation notification and responds to the
|
||||
// invitation, accepting it.
|
||||
executor.run_until_parked();
|
||||
client_b.notification_store().update(cx_b, |store, cx| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
assert_eq!(store.unread_notification_count(), 1);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert_eq!(
|
||||
entry.notification,
|
||||
Notification::ChannelInvitation {
|
||||
channel_id: channel_id.0,
|
||||
channel_name: "the-channel".to_string(),
|
||||
inviter_id: client_a.id()
|
||||
}
|
||||
);
|
||||
assert!(!entry.is_read);
|
||||
|
||||
store.respond_to_notification(entry.notification.clone(), true, cx);
|
||||
});
|
||||
|
||||
// Client B sees the notification is now read, and that they responded.
|
||||
executor.run_until_parked();
|
||||
client_b.notification_store().read_with(cx_b, |store, _| {
|
||||
assert_eq!(store.notification_count(), 2);
|
||||
assert_eq!(store.unread_notification_count(), 0);
|
||||
|
||||
let entry = store.notification_at(0).unwrap();
|
||||
assert!(entry.is_read);
|
||||
assert_eq!(entry.response, Some(true));
|
||||
});
|
||||
}
|
||||
285
crates/collab/tests/integration/random_channel_buffer_tests.rs
Normal file
285
crates/collab/tests/integration/random_channel_buffer_tests.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
use collab::db::*;
|
||||
|
||||
use super::{RandomizedTest, TestClient, TestError, TestServer, UserTestPlan, run_randomized_test};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use gpui::{BackgroundExecutor, SharedString, TestAppContext};
|
||||
use rand::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
ops::{Deref, DerefMut, Range},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use text::Bias;
|
||||
|
||||
#[gpui::test(iterations = 100, on_failure = "crate::save_randomized_test_plan")]
|
||||
async fn test_random_channel_buffers(
|
||||
cx: &mut TestAppContext,
|
||||
executor: BackgroundExecutor,
|
||||
rng: StdRng,
|
||||
) {
|
||||
run_randomized_test::<RandomChannelBufferTest>(cx, executor, rng).await;
|
||||
}
|
||||
|
||||
struct RandomChannelBufferTest;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
enum ChannelBufferOperation {
|
||||
JoinChannelNotes {
|
||||
channel_name: SharedString,
|
||||
},
|
||||
LeaveChannelNotes {
|
||||
channel_name: SharedString,
|
||||
},
|
||||
EditChannelNotes {
|
||||
channel_name: SharedString,
|
||||
edits: Vec<(Range<usize>, Arc<str>)>,
|
||||
},
|
||||
Noop,
|
||||
}
|
||||
|
||||
const CHANNEL_COUNT: usize = 3;
|
||||
|
||||
#[async_trait(?Send)]
|
||||
impl RandomizedTest for RandomChannelBufferTest {
|
||||
type Operation = ChannelBufferOperation;
|
||||
|
||||
async fn initialize(server: &mut TestServer, users: &[UserTestPlan]) {
|
||||
let db = &server.app_state.db;
|
||||
for ix in 0..CHANNEL_COUNT {
|
||||
let id = db
|
||||
.create_root_channel(&format!("channel-{ix}"), users[0].user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
for user in &users[1..] {
|
||||
db.invite_channel_member(id, user.user_id, users[0].user_id, ChannelRole::Member)
|
||||
.await
|
||||
.unwrap();
|
||||
db.respond_to_channel_invite(id, user.user_id, true)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_operation(
|
||||
client: &TestClient,
|
||||
rng: &mut StdRng,
|
||||
_: &mut UserTestPlan,
|
||||
cx: &TestAppContext,
|
||||
) -> ChannelBufferOperation {
|
||||
let channel_store = client.channel_store().clone();
|
||||
let mut channel_buffers = client.channel_buffers();
|
||||
|
||||
// When signed out, we can't do anything unless a channel buffer is
|
||||
// already open.
|
||||
if channel_buffers.deref_mut().is_empty()
|
||||
&& channel_store.read_with(cx, |store, _| store.channel_count() == 0)
|
||||
{
|
||||
return ChannelBufferOperation::Noop;
|
||||
}
|
||||
|
||||
loop {
|
||||
match rng.random_range(0..100_u32) {
|
||||
0..=29 => {
|
||||
let channel_name = client.channel_store().read_with(cx, |store, cx| {
|
||||
store.ordered_channels().find_map(|(_, channel)| {
|
||||
if store.has_open_channel_buffer(channel.id, cx) {
|
||||
None
|
||||
} else {
|
||||
Some(channel.name.clone())
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(channel_name) = channel_name {
|
||||
break ChannelBufferOperation::JoinChannelNotes { channel_name };
|
||||
}
|
||||
}
|
||||
|
||||
30..=40 => {
|
||||
if let Some(buffer) = channel_buffers.deref().iter().choose(rng) {
|
||||
let channel_name =
|
||||
buffer.read_with(cx, |b, cx| b.channel(cx).unwrap().name.clone());
|
||||
break ChannelBufferOperation::LeaveChannelNotes { channel_name };
|
||||
}
|
||||
}
|
||||
|
||||
_ => {
|
||||
if let Some(buffer) = channel_buffers.deref().iter().choose(rng) {
|
||||
break buffer.read_with(cx, |b, cx| {
|
||||
let channel_name = b.channel(cx).unwrap().name.clone();
|
||||
let edits = b
|
||||
.buffer()
|
||||
.read_with(cx, |buffer, _| buffer.get_random_edits(rng, 3));
|
||||
ChannelBufferOperation::EditChannelNotes {
|
||||
channel_name,
|
||||
edits,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_operation(
|
||||
client: &TestClient,
|
||||
operation: ChannelBufferOperation,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Result<(), TestError> {
|
||||
match operation {
|
||||
ChannelBufferOperation::JoinChannelNotes { channel_name } => {
|
||||
let buffer = client.channel_store().update(cx, |store, cx| {
|
||||
let channel_id = store
|
||||
.ordered_channels()
|
||||
.find(|(_, c)| c.name == channel_name)
|
||||
.unwrap()
|
||||
.1
|
||||
.id;
|
||||
if store.has_open_channel_buffer(channel_id, cx) {
|
||||
Err(TestError::Inapplicable)
|
||||
} else {
|
||||
Ok(store.open_channel_buffer(channel_id, cx))
|
||||
}
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"{}: opening notes for channel {channel_name}",
|
||||
client.username
|
||||
);
|
||||
client.channel_buffers().deref_mut().insert(buffer.await?);
|
||||
}
|
||||
|
||||
ChannelBufferOperation::LeaveChannelNotes { channel_name } => {
|
||||
let buffer = cx.update(|cx| {
|
||||
let mut left_buffer = Err(TestError::Inapplicable);
|
||||
client.channel_buffers().deref_mut().retain(|buffer| {
|
||||
if buffer.read(cx).channel(cx).unwrap().name == channel_name {
|
||||
left_buffer = Ok(buffer.clone());
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
left_buffer
|
||||
})?;
|
||||
|
||||
log::info!(
|
||||
"{}: closing notes for channel {channel_name}",
|
||||
client.username
|
||||
);
|
||||
cx.update(|_| drop(buffer));
|
||||
}
|
||||
|
||||
ChannelBufferOperation::EditChannelNotes {
|
||||
channel_name,
|
||||
edits,
|
||||
} => {
|
||||
let channel_buffer = cx
|
||||
.read(|cx| {
|
||||
client
|
||||
.channel_buffers()
|
||||
.deref()
|
||||
.iter()
|
||||
.find(|buffer| {
|
||||
buffer.read(cx).channel(cx).unwrap().name == channel_name
|
||||
})
|
||||
.cloned()
|
||||
})
|
||||
.ok_or_else(|| TestError::Inapplicable)?;
|
||||
|
||||
log::info!(
|
||||
"{}: editing notes for channel {channel_name} with {:?}",
|
||||
client.username,
|
||||
edits
|
||||
);
|
||||
|
||||
channel_buffer.update(cx, |buffer, cx| {
|
||||
let buffer = buffer.buffer();
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
let snapshot = buffer.snapshot();
|
||||
buffer.edit(
|
||||
edits.into_iter().map(|(range, text)| {
|
||||
let start = snapshot.clip_offset(range.start, Bias::Left);
|
||||
let end = snapshot.clip_offset(range.end, Bias::Right);
|
||||
(start..end, text)
|
||||
}),
|
||||
None,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ChannelBufferOperation::Noop => Err(TestError::Inapplicable)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_quiesce(server: &mut TestServer, clients: &mut [(Rc<TestClient>, TestAppContext)]) {
|
||||
let channels = server.app_state.db.all_channels().await.unwrap();
|
||||
|
||||
for (client, client_cx) in clients.iter_mut() {
|
||||
client_cx.update(|cx| {
|
||||
client
|
||||
.channel_buffers()
|
||||
.deref_mut()
|
||||
.retain(|b| b.read(cx).is_connected());
|
||||
});
|
||||
}
|
||||
|
||||
for (channel_id, channel_name) in channels {
|
||||
let mut prev_text: Option<(u64, String)> = None;
|
||||
|
||||
let mut collaborator_user_ids = server
|
||||
.app_state
|
||||
.db
|
||||
.get_channel_buffer_collaborators(channel_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|id| id.to_proto())
|
||||
.collect::<Vec<_>>();
|
||||
collaborator_user_ids.sort();
|
||||
|
||||
for (client, client_cx) in clients.iter() {
|
||||
let user_id = client.user_id().unwrap();
|
||||
client_cx.read(|cx| {
|
||||
if let Some(channel_buffer) = client
|
||||
.channel_buffers()
|
||||
.deref()
|
||||
.iter()
|
||||
.find(|b| b.read(cx).channel_id.0 == channel_id.to_proto())
|
||||
{
|
||||
let channel_buffer = channel_buffer.read(cx);
|
||||
|
||||
// Assert that channel buffer's text matches other clients' copies.
|
||||
let text = channel_buffer.buffer().read(cx).text();
|
||||
if let Some((prev_user_id, prev_text)) = &prev_text {
|
||||
assert_eq!(
|
||||
&text,
|
||||
prev_text,
|
||||
"client {user_id} has different text than client {prev_user_id} for channel {channel_name}",
|
||||
);
|
||||
} else {
|
||||
prev_text = Some((user_id, text));
|
||||
}
|
||||
|
||||
// Assert that all clients and the server agree about who is present in the
|
||||
// channel buffer.
|
||||
let collaborators = channel_buffer.collaborators();
|
||||
let mut user_ids =
|
||||
collaborators.values().map(|c| c.user_id).collect::<Vec<_>>();
|
||||
user_ids.sort();
|
||||
assert_eq!(
|
||||
user_ids,
|
||||
collaborator_user_ids,
|
||||
"client {user_id} has different user ids for channel {channel_name} than the server",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
702
crates/collab/tests/integration/randomized_test_helpers.rs
Normal file
702
crates/collab/tests/integration/randomized_test_helpers.rs
Normal file
@@ -0,0 +1,702 @@
|
||||
use crate::{TestClient, TestServer};
|
||||
use async_trait::async_trait;
|
||||
use collab::{
|
||||
db::{self, NewUserParams, UserId},
|
||||
rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use gpui::{BackgroundExecutor, Task, TestAppContext};
|
||||
use parking_lot::Mutex;
|
||||
use rand::prelude::*;
|
||||
use rpc::RECEIVE_TIMEOUT;
|
||||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use settings::SettingsStore;
|
||||
use std::sync::OnceLock;
|
||||
use std::{
|
||||
env,
|
||||
path::PathBuf,
|
||||
rc::Rc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering::SeqCst},
|
||||
},
|
||||
};
|
||||
|
||||
fn plan_load_path() -> &'static Option<PathBuf> {
|
||||
static PLAN_LOAD_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
PLAN_LOAD_PATH.get_or_init(|| path_env_var("LOAD_PLAN"))
|
||||
}
|
||||
|
||||
fn plan_save_path() -> &'static Option<PathBuf> {
|
||||
static PLAN_SAVE_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
PLAN_SAVE_PATH.get_or_init(|| path_env_var("SAVE_PLAN"))
|
||||
}
|
||||
|
||||
fn max_peers() -> usize {
|
||||
static MAX_PEERS: OnceLock<usize> = OnceLock::new();
|
||||
*MAX_PEERS.get_or_init(|| {
|
||||
env::var("MAX_PEERS")
|
||||
.map(|i| i.parse().expect("invalid `MAX_PEERS` variable"))
|
||||
.unwrap_or(3)
|
||||
})
|
||||
}
|
||||
|
||||
fn max_operations() -> usize {
|
||||
static MAX_OPERATIONS: OnceLock<usize> = OnceLock::new();
|
||||
*MAX_OPERATIONS.get_or_init(|| {
|
||||
env::var("OPERATIONS")
|
||||
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
|
||||
.unwrap_or(10)
|
||||
})
|
||||
}
|
||||
|
||||
static LOADED_PLAN_JSON: Mutex<Option<Vec<u8>>> = Mutex::new(None);
|
||||
static LAST_PLAN: Mutex<Option<Box<dyn Send + FnOnce() -> Vec<u8>>>> = Mutex::new(None);
|
||||
|
||||
struct TestPlan<T: RandomizedTest> {
|
||||
rng: StdRng,
|
||||
replay: bool,
|
||||
stored_operations: Vec<(StoredOperation<T::Operation>, Arc<AtomicBool>)>,
|
||||
max_operations: usize,
|
||||
operation_ix: usize,
|
||||
users: Vec<UserTestPlan>,
|
||||
next_batch_id: usize,
|
||||
allow_server_restarts: bool,
|
||||
allow_client_reconnection: bool,
|
||||
allow_client_disconnection: bool,
|
||||
}
|
||||
|
||||
pub struct UserTestPlan {
|
||||
pub user_id: UserId,
|
||||
pub username: String,
|
||||
pub allow_client_disconnection: bool,
|
||||
next_root_id: usize,
|
||||
operation_ix: usize,
|
||||
online: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StoredOperation<T> {
|
||||
Server(ServerOperation),
|
||||
Client {
|
||||
user_id: UserId,
|
||||
batch_id: usize,
|
||||
operation: T,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
enum ServerOperation {
|
||||
AddConnection {
|
||||
user_id: UserId,
|
||||
},
|
||||
RemoveConnection {
|
||||
user_id: UserId,
|
||||
},
|
||||
BounceConnection {
|
||||
user_id: UserId,
|
||||
},
|
||||
RestartServer,
|
||||
MutateClients {
|
||||
batch_id: usize,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_deserializing)]
|
||||
user_ids: Vec<UserId>,
|
||||
quiesce: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub enum TestError {
|
||||
Inapplicable,
|
||||
Other(anyhow::Error),
|
||||
}
|
||||
|
||||
#[async_trait(?Send)]
|
||||
pub trait RandomizedTest: 'static + Sized {
|
||||
type Operation: Send + Clone + Serialize + DeserializeOwned;
|
||||
|
||||
fn generate_operation(
|
||||
client: &TestClient,
|
||||
rng: &mut StdRng,
|
||||
plan: &mut UserTestPlan,
|
||||
cx: &TestAppContext,
|
||||
) -> Self::Operation;
|
||||
|
||||
async fn apply_operation(
|
||||
client: &TestClient,
|
||||
operation: Self::Operation,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Result<(), TestError>;
|
||||
|
||||
async fn initialize(server: &mut TestServer, users: &[UserTestPlan]);
|
||||
|
||||
async fn on_client_added(_client: &Rc<TestClient>, _cx: &mut TestAppContext) {}
|
||||
|
||||
async fn on_quiesce(server: &mut TestServer, client: &mut [(Rc<TestClient>, TestAppContext)]);
|
||||
}
|
||||
|
||||
pub async fn run_randomized_test<T: RandomizedTest>(
|
||||
cx: &mut TestAppContext,
|
||||
executor: BackgroundExecutor,
|
||||
rng: StdRng,
|
||||
) {
|
||||
let mut server = TestServer::start(executor.clone()).await;
|
||||
let plan = TestPlan::<T>::new(&mut server, rng).await;
|
||||
|
||||
LAST_PLAN.lock().replace({
|
||||
let plan = plan.clone();
|
||||
Box::new(move || plan.lock().serialize())
|
||||
});
|
||||
|
||||
let mut clients = Vec::new();
|
||||
let mut client_tasks = Vec::new();
|
||||
let mut operation_channels = Vec::new();
|
||||
loop {
|
||||
let Some((next_operation, applied)) = plan.lock().next_server_operation(&clients) else {
|
||||
break;
|
||||
};
|
||||
applied.store(true, SeqCst);
|
||||
let did_apply = TestPlan::apply_server_operation(
|
||||
plan.clone(),
|
||||
executor.clone(),
|
||||
&mut server,
|
||||
&mut clients,
|
||||
&mut client_tasks,
|
||||
&mut operation_channels,
|
||||
next_operation,
|
||||
cx,
|
||||
)
|
||||
.await;
|
||||
if !did_apply {
|
||||
applied.store(false, SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
drop(operation_channels);
|
||||
futures::future::join_all(client_tasks).await;
|
||||
|
||||
executor.run_until_parked();
|
||||
T::on_quiesce(&mut server, &mut clients).await;
|
||||
|
||||
for (client, cx) in clients {
|
||||
cx.update(|cx| {
|
||||
for window in cx.windows() {
|
||||
window
|
||||
.update(cx, |_, window, _| window.remove_window())
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
cx.update(|cx| {
|
||||
let settings = cx.remove_global::<SettingsStore>();
|
||||
cx.clear_globals();
|
||||
cx.set_global(settings);
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
drop(client);
|
||||
});
|
||||
executor.run_until_parked();
|
||||
}
|
||||
|
||||
if let Some(path) = plan_save_path() {
|
||||
eprintln!("saved test plan to path {:?}", path);
|
||||
std::fs::write(path, plan.lock().serialize()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_randomized_test_plan() {
|
||||
if let Some(serialize_plan) = LAST_PLAN.lock().take()
|
||||
&& let Some(path) = plan_save_path()
|
||||
{
|
||||
eprintln!("saved test plan to path {:?}", path);
|
||||
std::fs::write(path, serialize_plan()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: RandomizedTest> TestPlan<T> {
|
||||
pub async fn new(server: &mut TestServer, mut rng: StdRng) -> Arc<Mutex<Self>> {
|
||||
let allow_server_restarts = rng.random_bool(0.7);
|
||||
let allow_client_reconnection = rng.random_bool(0.7);
|
||||
let allow_client_disconnection = rng.random_bool(0.1);
|
||||
|
||||
let mut users = Vec::new();
|
||||
for ix in 0..max_peers() {
|
||||
let username = format!("user-{}", ix + 1);
|
||||
let user_id = server
|
||||
.app_state
|
||||
.db
|
||||
.create_user(
|
||||
&format!("{username}@example.com"),
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: username.clone(),
|
||||
github_user_id: ix as i32,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.user_id;
|
||||
users.push(UserTestPlan {
|
||||
user_id,
|
||||
username,
|
||||
online: false,
|
||||
next_root_id: 0,
|
||||
operation_ix: 0,
|
||||
allow_client_disconnection,
|
||||
});
|
||||
}
|
||||
|
||||
T::initialize(server, &users).await;
|
||||
|
||||
let plan = Arc::new(Mutex::new(Self {
|
||||
replay: false,
|
||||
allow_server_restarts,
|
||||
allow_client_reconnection,
|
||||
allow_client_disconnection,
|
||||
stored_operations: Vec::new(),
|
||||
operation_ix: 0,
|
||||
next_batch_id: 0,
|
||||
max_operations: max_operations(),
|
||||
users,
|
||||
rng,
|
||||
}));
|
||||
|
||||
if let Some(path) = plan_load_path() {
|
||||
let json = LOADED_PLAN_JSON
|
||||
.lock()
|
||||
.get_or_insert_with(|| {
|
||||
eprintln!("loaded test plan from path {:?}", path);
|
||||
std::fs::read(path).unwrap()
|
||||
})
|
||||
.clone();
|
||||
plan.lock().deserialize(json);
|
||||
}
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
fn deserialize(&mut self, json: Vec<u8>) {
|
||||
let stored_operations: Vec<StoredOperation<T::Operation>> =
|
||||
serde_json::from_slice(&json).unwrap();
|
||||
self.replay = true;
|
||||
self.stored_operations = stored_operations
|
||||
.iter()
|
||||
.cloned()
|
||||
.enumerate()
|
||||
.map(|(i, mut operation)| {
|
||||
let did_apply = Arc::new(AtomicBool::new(false));
|
||||
if let StoredOperation::Server(ServerOperation::MutateClients {
|
||||
batch_id: current_batch_id,
|
||||
user_ids,
|
||||
..
|
||||
}) = &mut operation
|
||||
{
|
||||
assert!(user_ids.is_empty());
|
||||
user_ids.extend(stored_operations[i + 1..].iter().filter_map(|operation| {
|
||||
if let StoredOperation::Client {
|
||||
user_id, batch_id, ..
|
||||
} = operation
|
||||
&& batch_id == current_batch_id
|
||||
{
|
||||
return Some(user_id);
|
||||
}
|
||||
None
|
||||
}));
|
||||
user_ids.sort_unstable();
|
||||
}
|
||||
(operation, did_apply)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn serialize(&mut self) -> Vec<u8> {
|
||||
// Format each operation as one line
|
||||
let mut json = Vec::new();
|
||||
json.push(b'[');
|
||||
for (operation, applied) in &self.stored_operations {
|
||||
if !applied.load(SeqCst) {
|
||||
continue;
|
||||
}
|
||||
if json.len() > 1 {
|
||||
json.push(b',');
|
||||
}
|
||||
json.extend_from_slice(b"\n ");
|
||||
serde_json::to_writer(&mut json, operation).unwrap();
|
||||
}
|
||||
json.extend_from_slice(b"\n]\n");
|
||||
json
|
||||
}
|
||||
|
||||
fn next_server_operation(
|
||||
&mut self,
|
||||
clients: &[(Rc<TestClient>, TestAppContext)],
|
||||
) -> Option<(ServerOperation, Arc<AtomicBool>)> {
|
||||
if self.replay {
|
||||
while let Some(stored_operation) = self.stored_operations.get(self.operation_ix) {
|
||||
self.operation_ix += 1;
|
||||
if let (StoredOperation::Server(operation), applied) = stored_operation {
|
||||
return Some((operation.clone(), applied.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
let operation = self.generate_server_operation(clients)?;
|
||||
let applied = Arc::new(AtomicBool::new(false));
|
||||
self.stored_operations
|
||||
.push((StoredOperation::Server(operation.clone()), applied.clone()));
|
||||
Some((operation, applied))
|
||||
}
|
||||
}
|
||||
|
||||
fn next_client_operation(
|
||||
&mut self,
|
||||
client: &TestClient,
|
||||
current_batch_id: usize,
|
||||
cx: &TestAppContext,
|
||||
) -> Option<(T::Operation, Arc<AtomicBool>)> {
|
||||
let current_user_id = client.current_user_id(cx);
|
||||
let user_ix = self
|
||||
.users
|
||||
.iter()
|
||||
.position(|user| user.user_id == current_user_id)
|
||||
.unwrap();
|
||||
let user_plan = &mut self.users[user_ix];
|
||||
|
||||
if self.replay {
|
||||
while let Some(stored_operation) = self.stored_operations.get(user_plan.operation_ix) {
|
||||
user_plan.operation_ix += 1;
|
||||
if let (
|
||||
StoredOperation::Client {
|
||||
user_id, operation, ..
|
||||
},
|
||||
applied,
|
||||
) = stored_operation
|
||||
&& user_id == ¤t_user_id
|
||||
{
|
||||
return Some((operation.clone(), applied.clone()));
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
if self.operation_ix == self.max_operations {
|
||||
return None;
|
||||
}
|
||||
self.operation_ix += 1;
|
||||
let operation = T::generate_operation(
|
||||
client,
|
||||
&mut self.rng,
|
||||
self.users
|
||||
.iter_mut()
|
||||
.find(|user| user.user_id == current_user_id)
|
||||
.unwrap(),
|
||||
cx,
|
||||
);
|
||||
let applied = Arc::new(AtomicBool::new(false));
|
||||
self.stored_operations.push((
|
||||
StoredOperation::Client {
|
||||
user_id: current_user_id,
|
||||
batch_id: current_batch_id,
|
||||
operation: operation.clone(),
|
||||
},
|
||||
applied.clone(),
|
||||
));
|
||||
Some((operation, applied))
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_server_operation(
|
||||
&mut self,
|
||||
clients: &[(Rc<TestClient>, TestAppContext)],
|
||||
) -> Option<ServerOperation> {
|
||||
if self.operation_ix == self.max_operations {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(loop {
|
||||
break match self.rng.random_range(0..100) {
|
||||
0..=29 if clients.len() < self.users.len() => {
|
||||
let user = self
|
||||
.users
|
||||
.iter()
|
||||
.filter(|u| !u.online)
|
||||
.choose(&mut self.rng)
|
||||
.unwrap();
|
||||
self.operation_ix += 1;
|
||||
ServerOperation::AddConnection {
|
||||
user_id: user.user_id,
|
||||
}
|
||||
}
|
||||
30..=34 if clients.len() > 1 && self.allow_client_disconnection => {
|
||||
let (client, cx) = &clients[self.rng.random_range(0..clients.len())];
|
||||
let user_id = client.current_user_id(cx);
|
||||
self.operation_ix += 1;
|
||||
ServerOperation::RemoveConnection { user_id }
|
||||
}
|
||||
35..=39 if clients.len() > 1 && self.allow_client_reconnection => {
|
||||
let (client, cx) = &clients[self.rng.random_range(0..clients.len())];
|
||||
let user_id = client.current_user_id(cx);
|
||||
self.operation_ix += 1;
|
||||
ServerOperation::BounceConnection { user_id }
|
||||
}
|
||||
40..=44 if self.allow_server_restarts && clients.len() > 1 => {
|
||||
self.operation_ix += 1;
|
||||
ServerOperation::RestartServer
|
||||
}
|
||||
_ if !clients.is_empty() => {
|
||||
let count = self
|
||||
.rng
|
||||
.random_range(1..10)
|
||||
.min(self.max_operations - self.operation_ix);
|
||||
let batch_id = util::post_inc(&mut self.next_batch_id);
|
||||
let mut user_ids = (0..count)
|
||||
.map(|_| {
|
||||
let ix = self.rng.random_range(0..clients.len());
|
||||
let (client, cx) = &clients[ix];
|
||||
client.current_user_id(cx)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
user_ids.sort_unstable();
|
||||
ServerOperation::MutateClients {
|
||||
user_ids,
|
||||
batch_id,
|
||||
quiesce: self.rng.random_bool(0.7),
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_server_operation(
|
||||
plan: Arc<Mutex<Self>>,
|
||||
deterministic: BackgroundExecutor,
|
||||
server: &mut TestServer,
|
||||
clients: &mut Vec<(Rc<TestClient>, TestAppContext)>,
|
||||
client_tasks: &mut Vec<Task<()>>,
|
||||
operation_channels: &mut Vec<futures::channel::mpsc::UnboundedSender<usize>>,
|
||||
operation: ServerOperation,
|
||||
cx: &mut TestAppContext,
|
||||
) -> bool {
|
||||
match operation {
|
||||
ServerOperation::AddConnection { user_id } => {
|
||||
let username;
|
||||
{
|
||||
let mut plan = plan.lock();
|
||||
let user = plan.user(user_id);
|
||||
if user.online {
|
||||
return false;
|
||||
}
|
||||
user.online = true;
|
||||
username = user.username.clone();
|
||||
};
|
||||
log::info!("adding new connection for {}", username);
|
||||
|
||||
let mut client_cx = cx.new_app();
|
||||
|
||||
let (operation_tx, operation_rx) = futures::channel::mpsc::unbounded();
|
||||
let client = Rc::new(server.create_client(&mut client_cx, &username).await);
|
||||
operation_channels.push(operation_tx);
|
||||
clients.push((client.clone(), client_cx.clone()));
|
||||
|
||||
let foreground_executor = client_cx.foreground_executor().clone();
|
||||
let simulate_client =
|
||||
Self::simulate_client(plan.clone(), client, operation_rx, client_cx);
|
||||
client_tasks.push(foreground_executor.spawn(simulate_client));
|
||||
|
||||
log::info!("added connection for {}", username);
|
||||
}
|
||||
|
||||
ServerOperation::RemoveConnection {
|
||||
user_id: removed_user_id,
|
||||
} => {
|
||||
log::info!("simulating full disconnection of user {}", removed_user_id);
|
||||
let client_ix = clients
|
||||
.iter()
|
||||
.position(|(client, cx)| client.current_user_id(cx) == removed_user_id);
|
||||
let Some(client_ix) = client_ix else {
|
||||
return false;
|
||||
};
|
||||
let user_connection_ids = server
|
||||
.connection_pool
|
||||
.lock()
|
||||
.user_connection_ids(removed_user_id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(user_connection_ids.len(), 1);
|
||||
let removed_peer_id = user_connection_ids[0].into();
|
||||
let (client, client_cx) = clients.remove(client_ix);
|
||||
let client_task = client_tasks.remove(client_ix);
|
||||
operation_channels.remove(client_ix);
|
||||
server.forbid_connections();
|
||||
server.disconnect_client(removed_peer_id);
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
log::info!("waiting for user {} to exit...", removed_user_id);
|
||||
client_task.await;
|
||||
server.allow_connections();
|
||||
|
||||
for project in client.dev_server_projects().iter() {
|
||||
project.read_with(&client_cx, |project, cx| {
|
||||
assert!(
|
||||
project.is_disconnected(cx),
|
||||
"project {:?} should be read only",
|
||||
project.remote_id()
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
for (client, cx) in clients {
|
||||
let contacts = server
|
||||
.app_state
|
||||
.db
|
||||
.get_contacts(client.current_user_id(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
let pool = server.connection_pool.lock();
|
||||
for contact in contacts {
|
||||
if let db::Contact::Accepted { user_id, busy, .. } = contact
|
||||
&& user_id == removed_user_id
|
||||
{
|
||||
assert!(!pool.is_user_online(user_id));
|
||||
assert!(!busy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("{} removed", client.username);
|
||||
plan.lock().user(removed_user_id).online = false;
|
||||
client_cx.update(|cx| {
|
||||
for window in cx.windows() {
|
||||
window
|
||||
.update(cx, |_, window, _| window.remove_window())
|
||||
.ok();
|
||||
}
|
||||
});
|
||||
client_cx.update(|cx| {
|
||||
cx.clear_globals();
|
||||
drop(client);
|
||||
});
|
||||
}
|
||||
|
||||
ServerOperation::BounceConnection { user_id } => {
|
||||
log::info!("simulating temporary disconnection of user {}", user_id);
|
||||
let user_connection_ids = server
|
||||
.connection_pool
|
||||
.lock()
|
||||
.user_connection_ids(user_id)
|
||||
.collect::<Vec<_>>();
|
||||
if user_connection_ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
assert_eq!(user_connection_ids.len(), 1);
|
||||
let peer_id = user_connection_ids[0].into();
|
||||
server.disconnect_client(peer_id);
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
}
|
||||
|
||||
ServerOperation::RestartServer => {
|
||||
log::info!("simulating server restart");
|
||||
server.reset().await;
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT);
|
||||
server.start().await.unwrap();
|
||||
deterministic.advance_clock(CLEANUP_TIMEOUT);
|
||||
let environment = &server.app_state.config.zed_environment;
|
||||
let (stale_room_ids, _) = server
|
||||
.app_state
|
||||
.db
|
||||
.stale_server_resource_ids(environment, server.id())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stale_room_ids, vec![]);
|
||||
}
|
||||
|
||||
ServerOperation::MutateClients {
|
||||
user_ids,
|
||||
batch_id,
|
||||
quiesce,
|
||||
} => {
|
||||
let mut applied = false;
|
||||
for user_id in user_ids {
|
||||
let client_ix = clients
|
||||
.iter()
|
||||
.position(|(client, cx)| client.current_user_id(cx) == user_id);
|
||||
let Some(client_ix) = client_ix else { continue };
|
||||
applied = true;
|
||||
if let Err(err) = operation_channels[client_ix].unbounded_send(batch_id) {
|
||||
log::error!("error signaling user {user_id}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
if quiesce && applied {
|
||||
deterministic.run_until_parked();
|
||||
T::on_quiesce(server, clients).await;
|
||||
}
|
||||
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn simulate_client(
|
||||
plan: Arc<Mutex<Self>>,
|
||||
client: Rc<TestClient>,
|
||||
mut operation_rx: futures::channel::mpsc::UnboundedReceiver<usize>,
|
||||
mut cx: TestAppContext,
|
||||
) {
|
||||
T::on_client_added(&client, &mut cx).await;
|
||||
|
||||
while let Some(batch_id) = operation_rx.next().await {
|
||||
let Some((operation, applied)) =
|
||||
plan.lock().next_client_operation(&client, batch_id, &cx)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
applied.store(true, SeqCst);
|
||||
match T::apply_operation(&client, operation, &mut cx).await {
|
||||
Ok(()) => {}
|
||||
Err(TestError::Inapplicable) => {
|
||||
applied.store(false, SeqCst);
|
||||
log::info!("skipped operation");
|
||||
}
|
||||
Err(TestError::Other(error)) => {
|
||||
log::error!("{} error: {}", client.username, error);
|
||||
}
|
||||
}
|
||||
cx.executor().simulate_random_delay().await;
|
||||
}
|
||||
log::info!("{}: done", client.username);
|
||||
}
|
||||
|
||||
fn user(&mut self, user_id: UserId) -> &mut UserTestPlan {
|
||||
self.users
|
||||
.iter_mut()
|
||||
.find(|user| user.user_id == user_id)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl UserTestPlan {
|
||||
pub fn next_root_dir_name(&mut self) -> String {
|
||||
let user_id = self.user_id;
|
||||
let root_id = util::post_inc(&mut self.next_root_id);
|
||||
format!("dir-{user_id}-{root_id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for TestError {
|
||||
fn from(value: anyhow::Error) -> Self {
|
||||
Self::Other(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_env_var(name: &str) -> Option<PathBuf> {
|
||||
let value = env::var(name).ok()?;
|
||||
let mut path = PathBuf::from(value);
|
||||
if path.is_relative() {
|
||||
let mut abs_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
abs_path.pop();
|
||||
abs_path.pop();
|
||||
abs_path.push(path);
|
||||
path = abs_path
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
976
crates/collab/tests/integration/test_server.rs
Normal file
976
crates/collab/tests/integration/test_server.rs
Normal file
@@ -0,0 +1,976 @@
|
||||
use anyhow::anyhow;
|
||||
use call::ActiveCall;
|
||||
use channel::{ChannelBuffer, ChannelStore};
|
||||
use client::test::{make_get_authenticated_user_response, parse_authorization_header};
|
||||
use client::{
|
||||
self, ChannelId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
|
||||
proto::PeerId,
|
||||
};
|
||||
use clock::FakeSystemClock;
|
||||
use collab::services::{FakeUserService, NewUserParams};
|
||||
use collab::{
|
||||
AppState, Config,
|
||||
db::UserId,
|
||||
executor::Executor,
|
||||
rpc::{CLEANUP_TIMEOUT, Principal, RECONNECT_TIMEOUT, Server, ZedVersion},
|
||||
};
|
||||
use collab_ui::channel_view::ChannelView;
|
||||
use collections::{HashMap, HashSet};
|
||||
|
||||
use fs::FakeFs;
|
||||
use futures::{StreamExt as _, channel::oneshot};
|
||||
use git::GitHostingProviderRegistry;
|
||||
use gpui::{AppContext as _, BackgroundExecutor, Entity, Task, TestAppContext, VisualTestContext};
|
||||
use http_client::{FakeHttpClient, Method};
|
||||
use language::LanguageRegistry;
|
||||
use node_runtime::NodeRuntime;
|
||||
use notifications::NotificationStore;
|
||||
use parking_lot::Mutex;
|
||||
use project::{Project, WorktreeId};
|
||||
use remote::RemoteClient;
|
||||
use rpc::{
|
||||
RECEIVE_TIMEOUT,
|
||||
proto::{self, ChannelRole},
|
||||
};
|
||||
use serde_json::json;
|
||||
use session::{AppSession, Session};
|
||||
use settings::SettingsStore;
|
||||
use std::{
|
||||
cell::{Ref, RefCell, RefMut},
|
||||
env,
|
||||
ops::{Deref, DerefMut},
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst},
|
||||
},
|
||||
};
|
||||
use util::path;
|
||||
use workspace::{MultiWorkspace, Workspace, WorkspaceStore};
|
||||
|
||||
use livekit_client::test::TestServer as LivekitTestServer;
|
||||
|
||||
use crate::db_tests::TestDb;
|
||||
|
||||
pub struct TestServer {
|
||||
pub app_state: Arc<AppState>,
|
||||
pub test_livekit_server: Arc<LivekitTestServer>,
|
||||
pub test_db: TestDb,
|
||||
server: Arc<Server>,
|
||||
next_github_user_id: i32,
|
||||
connection_killers: Arc<Mutex<HashMap<PeerId, Arc<AtomicBool>>>>,
|
||||
forbid_connections: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
pub struct TestClient {
|
||||
pub username: String,
|
||||
pub app_state: Arc<workspace::AppState>,
|
||||
channel_store: Entity<ChannelStore>,
|
||||
notification_store: Entity<NotificationStore>,
|
||||
state: RefCell<TestClientState>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestClientState {
|
||||
local_projects: Vec<Entity<Project>>,
|
||||
dev_server_projects: Vec<Entity<Project>>,
|
||||
buffers: HashMap<Entity<Project>, HashSet<Entity<language::Buffer>>>,
|
||||
channel_buffers: HashSet<Entity<ChannelBuffer>>,
|
||||
}
|
||||
|
||||
pub struct ContactsSummary {
|
||||
pub current: Vec<String>,
|
||||
pub outgoing_requests: Vec<String>,
|
||||
pub incoming_requests: Vec<String>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
pub async fn start(deterministic: BackgroundExecutor) -> Self {
|
||||
static NEXT_LIVEKIT_SERVER_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
let use_postgres = env::var("USE_POSTGRES").ok();
|
||||
let use_postgres = use_postgres.as_deref();
|
||||
let test_db = if use_postgres == Some("true") || use_postgres == Some("1") {
|
||||
TestDb::postgres(deterministic.clone())
|
||||
} else {
|
||||
TestDb::sqlite(deterministic.clone())
|
||||
};
|
||||
let livekit_server_id = NEXT_LIVEKIT_SERVER_ID.fetch_add(1, SeqCst);
|
||||
let livekit_server = LivekitTestServer::create(
|
||||
format!("http://livekit.{}.test", livekit_server_id),
|
||||
format!("devkey-{}", livekit_server_id),
|
||||
format!("secret-{}", livekit_server_id),
|
||||
deterministic.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let executor = Executor::Deterministic(deterministic.clone());
|
||||
let app_state = Self::build_app_state(&test_db, &livekit_server, executor.clone()).await;
|
||||
let epoch = app_state
|
||||
.db
|
||||
.create_server(&app_state.config.zed_environment)
|
||||
.await
|
||||
.unwrap();
|
||||
let server = Server::new(epoch, app_state.clone());
|
||||
server.start().await.unwrap();
|
||||
// Advance clock to ensure the server's cleanup task is finished.
|
||||
deterministic.advance_clock(CLEANUP_TIMEOUT);
|
||||
Self {
|
||||
app_state,
|
||||
server,
|
||||
connection_killers: Default::default(),
|
||||
forbid_connections: Default::default(),
|
||||
next_github_user_id: 0,
|
||||
test_db,
|
||||
test_livekit_server: livekit_server,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start2(
|
||||
cx_a: &mut TestAppContext,
|
||||
cx_b: &mut TestAppContext,
|
||||
) -> (TestServer, TestClient, TestClient, ChannelId) {
|
||||
let mut server = Self::start(cx_a.executor()).await;
|
||||
let client_a = server.create_client(cx_a, "user_a").await;
|
||||
let client_b = server.create_client(cx_b, "user_b").await;
|
||||
let channel_id = server
|
||||
.make_channel(
|
||||
"test-channel",
|
||||
None,
|
||||
(&client_a, cx_a),
|
||||
&mut [(&client_b, cx_b)],
|
||||
)
|
||||
.await;
|
||||
cx_a.run_until_parked();
|
||||
|
||||
(server, client_a, client_b, channel_id)
|
||||
}
|
||||
|
||||
pub async fn start1(cx: &mut TestAppContext) -> (TestServer, TestClient) {
|
||||
let mut server = Self::start(cx.executor().clone()).await;
|
||||
let client = server.create_client(cx, "user_a").await;
|
||||
(server, client)
|
||||
}
|
||||
|
||||
pub async fn reset(&self) {
|
||||
self.app_state.db.reset();
|
||||
let epoch = self
|
||||
.app_state
|
||||
.db
|
||||
.create_server(&self.app_state.config.zed_environment)
|
||||
.await
|
||||
.unwrap();
|
||||
self.server.reset(epoch);
|
||||
}
|
||||
|
||||
pub async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
|
||||
const ACCESS_TOKEN: &str = "the-token";
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
|
||||
cx.update(|cx| {
|
||||
gpui_tokio::init(cx);
|
||||
if cx.has_global::<SettingsStore>() {
|
||||
panic!("Same cx used to create two test clients")
|
||||
}
|
||||
let settings = SettingsStore::test(cx);
|
||||
cx.set_global(settings);
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
release_channel::init(semver::Version::new(0, 0, 0), cx);
|
||||
});
|
||||
|
||||
let clock = Arc::new(FakeSystemClock::new());
|
||||
|
||||
let user_id = if let Ok(Some(user)) = self
|
||||
.app_state
|
||||
.user_service
|
||||
.get_user_by_github_login(name)
|
||||
.await
|
||||
{
|
||||
user.id
|
||||
} else {
|
||||
let github_user_id = self.next_github_user_id;
|
||||
self.next_github_user_id += 1;
|
||||
self.app_state
|
||||
.user_service
|
||||
.as_fake()
|
||||
.create_user(
|
||||
&format!("{name}@example.com"),
|
||||
None,
|
||||
false,
|
||||
NewUserParams {
|
||||
github_login: name.into(),
|
||||
github_user_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
};
|
||||
|
||||
let http = FakeHttpClient::create({
|
||||
let name = name.to_string();
|
||||
move |req| {
|
||||
let name = name.clone();
|
||||
async move {
|
||||
match (req.method(), req.uri().path()) {
|
||||
(&Method::GET, "/client/users/me") => {
|
||||
let credentials = parse_authorization_header(&req);
|
||||
if credentials
|
||||
!= Some(Credentials {
|
||||
user_id: user_id.to_proto(),
|
||||
access_token: ACCESS_TOKEN.into(),
|
||||
})
|
||||
{
|
||||
return Ok(http_client::Response::builder()
|
||||
.status(401)
|
||||
.body("Unauthorized".into())
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body(
|
||||
serde_json::to_string(&make_get_authenticated_user_response(
|
||||
user_id.0, name,
|
||||
))
|
||||
.unwrap()
|
||||
.into(),
|
||||
)
|
||||
.unwrap())
|
||||
}
|
||||
_ => Ok(http_client::Response::builder()
|
||||
.status(404)
|
||||
.body("Not Found".into())
|
||||
.unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client_name = name.to_string();
|
||||
let client = cx.update(|cx| Client::new(clock, http.clone(), cx));
|
||||
let server = self.server.clone();
|
||||
let user_service = self.app_state.user_service.clone();
|
||||
let connection_killers = self.connection_killers.clone();
|
||||
let forbid_connections = self.forbid_connections.clone();
|
||||
|
||||
client
|
||||
.set_id(user_id.to_proto())
|
||||
.override_authenticate(move |cx| {
|
||||
cx.spawn(async move |_| {
|
||||
Ok(Credentials {
|
||||
user_id: user_id.to_proto(),
|
||||
access_token: ACCESS_TOKEN.into(),
|
||||
})
|
||||
})
|
||||
})
|
||||
.override_establish_connection(move |credentials, cx| {
|
||||
assert_eq!(
|
||||
credentials,
|
||||
&Credentials {
|
||||
user_id: user_id.0 as u64,
|
||||
access_token: ACCESS_TOKEN.into(),
|
||||
}
|
||||
);
|
||||
|
||||
let server = server.clone();
|
||||
let user_service = user_service.clone();
|
||||
let connection_killers = connection_killers.clone();
|
||||
let forbid_connections = forbid_connections.clone();
|
||||
let client_name = client_name.clone();
|
||||
cx.spawn(async move |cx| {
|
||||
if forbid_connections.load(SeqCst) {
|
||||
Err(EstablishConnectionError::other(anyhow!(
|
||||
"server is forbidding connections"
|
||||
)))
|
||||
} else {
|
||||
let (client_conn, server_conn, killed) =
|
||||
Connection::in_memory(cx.background_executor().clone());
|
||||
let (connection_id_tx, connection_id_rx) = oneshot::channel();
|
||||
let user = user_service
|
||||
.as_fake()
|
||||
.get_user_by_id(user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
EstablishConnectionError::Other(anyhow!(
|
||||
"retrieving user failed: {}",
|
||||
e
|
||||
))
|
||||
})?
|
||||
.unwrap();
|
||||
cx.background_spawn(server.handle_connection(
|
||||
server_conn,
|
||||
client_name,
|
||||
Principal::User(user),
|
||||
ZedVersion(semver::Version::new(1, 0, 0)),
|
||||
Some("test".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(connection_id_tx),
|
||||
Executor::Deterministic(cx.background_executor().clone()),
|
||||
None,
|
||||
))
|
||||
.detach();
|
||||
let connection_id = connection_id_rx.await.map_err(|e| {
|
||||
EstablishConnectionError::Other(anyhow!(
|
||||
"{} (is server shutting down?)",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
connection_killers
|
||||
.lock()
|
||||
.insert(connection_id.into(), killed);
|
||||
Ok(client_conn)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let git_hosting_provider_registry = cx.update(GitHostingProviderRegistry::default_global);
|
||||
git_hosting_provider_registry
|
||||
.register_hosting_provider(Arc::new(git_hosting_providers::Github::public_instance()));
|
||||
|
||||
let user_store = cx.new(|cx| UserStore::new(client.clone(), cx));
|
||||
let workspace_store = cx.new(|cx| WorkspaceStore::new(client.clone(), cx));
|
||||
let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
|
||||
let session = cx.new(|cx| AppSession::new(Session::test(), cx));
|
||||
let app_state = Arc::new(workspace::AppState {
|
||||
client: client.clone(),
|
||||
user_store: user_store.clone(),
|
||||
workspace_store,
|
||||
languages: language_registry,
|
||||
fs: fs.clone(),
|
||||
build_window_options: |_, _| Default::default(),
|
||||
node_runtime: NodeRuntime::unavailable(),
|
||||
session,
|
||||
});
|
||||
|
||||
let os_keymap = "keymaps/default-macos.json";
|
||||
|
||||
cx.update(|cx| {
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
Project::init(&client, cx);
|
||||
client::init(&client, cx);
|
||||
editor::init(cx);
|
||||
workspace::init(app_state.clone(), cx);
|
||||
call::init(client.clone(), user_store.clone(), cx);
|
||||
channel::init(&client, user_store.clone(), cx);
|
||||
notifications::init(client.clone(), user_store, cx);
|
||||
collab_ui::init(&app_state, cx);
|
||||
file_finder::init(cx);
|
||||
menu::init();
|
||||
cx.bind_keys(
|
||||
settings::KeymapFile::load_asset_allow_partial_failure(os_keymap, cx).unwrap(),
|
||||
);
|
||||
language_model::LanguageModelRegistry::test(cx);
|
||||
});
|
||||
|
||||
client
|
||||
.connect(false, &cx.to_async())
|
||||
.await
|
||||
.into_response()
|
||||
.unwrap();
|
||||
|
||||
let client = TestClient {
|
||||
app_state,
|
||||
username: name.to_string(),
|
||||
channel_store: cx.read(ChannelStore::global),
|
||||
notification_store: cx.read(NotificationStore::global),
|
||||
state: Default::default(),
|
||||
};
|
||||
client.wait_for_current_user(cx).await;
|
||||
client
|
||||
}
|
||||
|
||||
pub fn disconnect_client(&self, peer_id: PeerId) {
|
||||
self.connection_killers
|
||||
.lock()
|
||||
.remove(&peer_id)
|
||||
.unwrap()
|
||||
.store(true, SeqCst);
|
||||
}
|
||||
|
||||
pub fn simulate_long_connection_interruption(
|
||||
&self,
|
||||
peer_id: PeerId,
|
||||
deterministic: BackgroundExecutor,
|
||||
) {
|
||||
self.forbid_connections();
|
||||
self.disconnect_client(peer_id);
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
self.allow_connections();
|
||||
deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
|
||||
deterministic.run_until_parked();
|
||||
}
|
||||
|
||||
pub fn forbid_connections(&self) {
|
||||
self.forbid_connections.store(true, SeqCst);
|
||||
}
|
||||
|
||||
pub fn allow_connections(&self) {
|
||||
self.forbid_connections.store(false, SeqCst);
|
||||
}
|
||||
|
||||
pub async fn make_contacts(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) {
|
||||
for ix in 1..clients.len() {
|
||||
let (left, right) = clients.split_at_mut(ix);
|
||||
let (client_a, cx_a) = left.last_mut().unwrap();
|
||||
for (client_b, cx_b) in right {
|
||||
client_a
|
||||
.app_state
|
||||
.user_store
|
||||
.update(*cx_a, |store, cx| {
|
||||
store.request_contact(client_b.user_id().unwrap(), cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx_a.executor().run_until_parked();
|
||||
client_b
|
||||
.app_state
|
||||
.user_store
|
||||
.update(*cx_b, |store, cx| {
|
||||
store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn make_channel(
|
||||
&self,
|
||||
channel: &str,
|
||||
parent: Option<ChannelId>,
|
||||
admin: (&TestClient, &mut TestAppContext),
|
||||
members: &mut [(&TestClient, &mut TestAppContext)],
|
||||
) -> ChannelId {
|
||||
let (admin_client, admin_cx) = admin;
|
||||
|
||||
// Subscribe to channels (simulates opening the collab panel)
|
||||
admin_client.initialize_channel_store(admin_cx);
|
||||
admin_cx.executor().run_until_parked();
|
||||
|
||||
let channel_id = admin_cx
|
||||
.read(ChannelStore::global)
|
||||
.update(admin_cx, |channel_store, cx| {
|
||||
channel_store.create_channel(channel, parent, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (member_client, member_cx) in members {
|
||||
// Subscribe member to channels (simulates opening the collab panel)
|
||||
member_client.initialize_channel_store(member_cx);
|
||||
member_cx.executor().run_until_parked();
|
||||
|
||||
admin_cx
|
||||
.read(ChannelStore::global)
|
||||
.update(admin_cx, |channel_store, cx| {
|
||||
channel_store.invite_member(
|
||||
channel_id,
|
||||
member_client.user_id().unwrap(),
|
||||
ChannelRole::Member,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
admin_cx.executor().run_until_parked();
|
||||
|
||||
member_cx
|
||||
.read(ChannelStore::global)
|
||||
.update(*member_cx, |channels, cx| {
|
||||
channels.respond_to_channel_invite(channel_id, true, cx)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
channel_id
|
||||
}
|
||||
|
||||
pub async fn make_public_channel(
|
||||
&self,
|
||||
channel: &str,
|
||||
client: &TestClient,
|
||||
cx: &mut TestAppContext,
|
||||
) -> ChannelId {
|
||||
let channel_id = self
|
||||
.make_channel(channel, None, (client, cx), &mut [])
|
||||
.await;
|
||||
|
||||
client
|
||||
.channel_store()
|
||||
.update(cx, |channel_store, cx| {
|
||||
channel_store.set_channel_visibility(
|
||||
channel_id,
|
||||
proto::ChannelVisibility::Public,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
channel_id
|
||||
}
|
||||
|
||||
pub async fn make_channel_tree(
|
||||
&self,
|
||||
channels: &[(&str, Option<&str>)],
|
||||
creator: (&TestClient, &mut TestAppContext),
|
||||
) -> Vec<ChannelId> {
|
||||
let mut observed_channels = HashMap::default();
|
||||
let mut result = Vec::new();
|
||||
for (channel, parent) in channels {
|
||||
let id;
|
||||
if let Some(parent) = parent {
|
||||
if let Some(parent_id) = observed_channels.get(parent) {
|
||||
id = self
|
||||
.make_channel(channel, Some(*parent_id), (creator.0, creator.1), &mut [])
|
||||
.await;
|
||||
} else {
|
||||
panic!(
|
||||
"Edge {}->{} referenced before {} was created",
|
||||
parent, channel, parent
|
||||
)
|
||||
}
|
||||
} else {
|
||||
id = self
|
||||
.make_channel(channel, None, (creator.0, creator.1), &mut [])
|
||||
.await;
|
||||
}
|
||||
|
||||
observed_channels.insert(channel, id);
|
||||
result.push(id);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) {
|
||||
self.make_contacts(clients).await;
|
||||
|
||||
let (left, right) = clients.split_at_mut(1);
|
||||
let (_client_a, cx_a) = &mut left[0];
|
||||
let active_call_a = cx_a.read(ActiveCall::global);
|
||||
|
||||
for (client_b, cx_b) in right {
|
||||
let user_id_b = client_b.current_user_id(cx_b).to_proto();
|
||||
active_call_a
|
||||
.update(*cx_a, |call, cx| call.invite(user_id_b, None, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
cx_b.executor().run_until_parked();
|
||||
let active_call_b = cx_b.read(ActiveCall::global);
|
||||
active_call_b
|
||||
.update(*cx_b, |call, cx| call.accept_incoming(cx))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_app_state(
|
||||
test_db: &TestDb,
|
||||
livekit_test_server: &LivekitTestServer,
|
||||
executor: Executor,
|
||||
) -> Arc<AppState> {
|
||||
Arc::new(AppState {
|
||||
db: test_db.db().clone(),
|
||||
http_client: None,
|
||||
livekit_client: Some(Arc::new(livekit_test_server.create_api_client())),
|
||||
blob_store_client: None,
|
||||
executor,
|
||||
kinesis_client: None,
|
||||
user_service: FakeUserService::new(test_db.db().clone()),
|
||||
config: Config {
|
||||
http_port: 0,
|
||||
database_url: "".into(),
|
||||
database_max_connections: 0,
|
||||
livekit_server: None,
|
||||
livekit_key: None,
|
||||
livekit_secret: None,
|
||||
rust_log: None,
|
||||
log_json: None,
|
||||
zed_environment: "test".into(),
|
||||
zed_cloud_internal_api_key: "test-internal-api-key".into(),
|
||||
blob_store_url: None,
|
||||
blob_store_region: None,
|
||||
blob_store_access_key: None,
|
||||
blob_store_secret_key: None,
|
||||
blob_store_bucket: None,
|
||||
zed_client_checksum_seed: None,
|
||||
seed_path: None,
|
||||
kinesis_region: None,
|
||||
kinesis_stream: None,
|
||||
kinesis_access_key: None,
|
||||
kinesis_secret_key: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TestServer {
|
||||
type Target = Server;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.server
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.server.teardown();
|
||||
self.test_livekit_server.teardown().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TestClient {
|
||||
type Target = Arc<Client>;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.app_state.client
|
||||
}
|
||||
}
|
||||
|
||||
impl TestClient {
|
||||
pub fn fs(&self) -> Arc<FakeFs> {
|
||||
self.app_state.fs.as_fake()
|
||||
}
|
||||
|
||||
pub fn channel_store(&self) -> &Entity<ChannelStore> {
|
||||
&self.channel_store
|
||||
}
|
||||
|
||||
pub fn notification_store(&self) -> &Entity<NotificationStore> {
|
||||
&self.notification_store
|
||||
}
|
||||
|
||||
pub fn user_store(&self) -> &Entity<UserStore> {
|
||||
&self.app_state.user_store
|
||||
}
|
||||
|
||||
pub fn language_registry(&self) -> &Arc<LanguageRegistry> {
|
||||
&self.app_state.languages
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &Arc<Client> {
|
||||
&self.app_state.client
|
||||
}
|
||||
|
||||
pub fn current_user_id(&self, cx: &TestAppContext) -> UserId {
|
||||
UserId::from_proto(self.app_state.user_store.read_with(cx, |user_store, _| {
|
||||
user_store.current_user().unwrap().legacy_id
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn wait_for_current_user(&self, cx: &TestAppContext) {
|
||||
let mut authed_user = self
|
||||
.app_state
|
||||
.user_store
|
||||
.read_with(cx, |user_store, _| user_store.watch_current_user());
|
||||
while authed_user.next().await.unwrap().is_none() {}
|
||||
}
|
||||
|
||||
pub async fn clear_contacts(&self, cx: &mut TestAppContext) {
|
||||
self.app_state
|
||||
.user_store
|
||||
.update(cx, |store, _| store.clear_contacts())
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Subscribe to channels. In production this happens when the user opens the collab panel.
|
||||
pub fn initialize_channel_store(&self, cx: &mut TestAppContext) {
|
||||
self.channel_store
|
||||
.update(cx, |channel_store, _| channel_store.initialize());
|
||||
}
|
||||
|
||||
pub fn local_projects(&self) -> impl Deref<Target = Vec<Entity<Project>>> + '_ {
|
||||
Ref::map(self.state.borrow(), |state| &state.local_projects)
|
||||
}
|
||||
|
||||
pub fn dev_server_projects(&self) -> impl Deref<Target = Vec<Entity<Project>>> + '_ {
|
||||
Ref::map(self.state.borrow(), |state| &state.dev_server_projects)
|
||||
}
|
||||
|
||||
pub fn local_projects_mut(&self) -> impl DerefMut<Target = Vec<Entity<Project>>> + '_ {
|
||||
RefMut::map(self.state.borrow_mut(), |state| &mut state.local_projects)
|
||||
}
|
||||
|
||||
pub fn dev_server_projects_mut(&self) -> impl DerefMut<Target = Vec<Entity<Project>>> + '_ {
|
||||
RefMut::map(self.state.borrow_mut(), |state| {
|
||||
&mut state.dev_server_projects
|
||||
})
|
||||
}
|
||||
|
||||
pub fn buffers_for_project<'a>(
|
||||
&'a self,
|
||||
project: &Entity<Project>,
|
||||
) -> impl DerefMut<Target = HashSet<Entity<language::Buffer>>> + 'a {
|
||||
RefMut::map(self.state.borrow_mut(), |state| {
|
||||
state.buffers.entry(project.clone()).or_default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn buffers(
|
||||
&self,
|
||||
) -> impl DerefMut<Target = HashMap<Entity<Project>, HashSet<Entity<language::Buffer>>>> + '_
|
||||
{
|
||||
RefMut::map(self.state.borrow_mut(), |state| &mut state.buffers)
|
||||
}
|
||||
|
||||
pub fn channel_buffers(&self) -> impl DerefMut<Target = HashSet<Entity<ChannelBuffer>>> + '_ {
|
||||
RefMut::map(self.state.borrow_mut(), |state| &mut state.channel_buffers)
|
||||
}
|
||||
|
||||
pub fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary {
|
||||
self.app_state
|
||||
.user_store
|
||||
.read_with(cx, |store, _| ContactsSummary {
|
||||
current: store
|
||||
.contacts()
|
||||
.iter()
|
||||
.map(|contact| contact.user.github_login.clone().to_string())
|
||||
.collect(),
|
||||
outgoing_requests: store
|
||||
.outgoing_contact_requests()
|
||||
.iter()
|
||||
.map(|user| user.github_login.clone().to_string())
|
||||
.collect(),
|
||||
incoming_requests: store
|
||||
.incoming_contact_requests()
|
||||
.iter()
|
||||
.map(|user| user.github_login.clone().to_string())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn build_local_project(
|
||||
&self,
|
||||
root_path: impl AsRef<Path>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Project>, WorktreeId) {
|
||||
let project = self.build_empty_local_project(false, cx);
|
||||
let (worktree, _) = project
|
||||
.update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
worktree
|
||||
.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
(project, worktree.read_with(cx, |tree, _| tree.id()))
|
||||
}
|
||||
|
||||
pub async fn build_local_project_with_trust(
|
||||
&self,
|
||||
root_path: impl AsRef<Path>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Project>, WorktreeId) {
|
||||
let project = self.build_empty_local_project(true, cx);
|
||||
let (worktree, _) = project
|
||||
.update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
worktree
|
||||
.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
|
||||
.await;
|
||||
cx.run_until_parked();
|
||||
(project, worktree.read_with(cx, |tree, _| tree.id()))
|
||||
}
|
||||
|
||||
pub async fn build_ssh_project(
|
||||
&self,
|
||||
root_path: impl AsRef<Path>,
|
||||
ssh: Entity<RemoteClient>,
|
||||
init_worktree_trust: bool,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Project>, WorktreeId) {
|
||||
let project = cx.update(|cx| {
|
||||
Project::remote(
|
||||
ssh,
|
||||
self.client().clone(),
|
||||
self.app_state.node_runtime.clone(),
|
||||
self.app_state.user_store.clone(),
|
||||
self.app_state.languages.clone(),
|
||||
self.app_state.fs.clone(),
|
||||
init_worktree_trust,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
let (worktree, _) = project
|
||||
.update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
(project, worktree.read_with(cx, |tree, _| tree.id()))
|
||||
}
|
||||
|
||||
pub async fn build_test_project(&self, cx: &mut TestAppContext) -> Entity<Project> {
|
||||
self.fs()
|
||||
.insert_tree(
|
||||
path!("/a"),
|
||||
json!({
|
||||
"1.txt": "one\none\none",
|
||||
"2.js": "function two() { return 2; }",
|
||||
"3.rs": "mod test",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
self.build_local_project(path!("/a"), cx).await.0
|
||||
}
|
||||
|
||||
pub async fn host_workspace(
|
||||
&self,
|
||||
workspace: &Entity<Workspace>,
|
||||
channel_id: ChannelId,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
cx.update(|_, cx| {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
active_call.update(cx, |call, cx| call.join_channel(channel_id, cx))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.update(|_, cx| {
|
||||
let active_call = ActiveCall::global(cx);
|
||||
let project = workspace.read(cx).project().clone();
|
||||
active_call.update(cx, |call, cx| call.share_project(project, cx))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cx.executor().run_until_parked();
|
||||
}
|
||||
|
||||
pub async fn join_workspace<'a>(
|
||||
&'a self,
|
||||
channel_id: ChannelId,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
|
||||
cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, None, cx))
|
||||
.await
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
|
||||
self.active_workspace(cx)
|
||||
}
|
||||
|
||||
pub fn build_empty_local_project(
|
||||
&self,
|
||||
init_worktree_trust: bool,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Entity<Project> {
|
||||
cx.update(|cx| {
|
||||
Project::local(
|
||||
self.client().clone(),
|
||||
self.app_state.node_runtime.clone(),
|
||||
self.app_state.user_store.clone(),
|
||||
self.app_state.languages.clone(),
|
||||
self.app_state.fs.clone(),
|
||||
None,
|
||||
project::LocalProjectFlags {
|
||||
init_worktree_trust,
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn join_remote_project(
|
||||
&self,
|
||||
host_project_id: u64,
|
||||
guest_cx: &mut TestAppContext,
|
||||
) -> Entity<Project> {
|
||||
let active_call = guest_cx.read(ActiveCall::global);
|
||||
let room = active_call.read_with(guest_cx, |call, _| call.room().unwrap().clone());
|
||||
room.update(guest_cx, |room, cx| {
|
||||
room.join_project(
|
||||
host_project_id,
|
||||
self.app_state.languages.clone(),
|
||||
self.app_state.fs.clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn build_workspace<'a>(
|
||||
&'a self,
|
||||
project: &Entity<Project>,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
|
||||
let app_state = self.app_state.clone();
|
||||
let project = project.clone();
|
||||
let window = cx.add_window(|window, cx| {
|
||||
window.activate_window();
|
||||
let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
|
||||
MultiWorkspace::new(workspace, window, cx)
|
||||
});
|
||||
let cx = VisualTestContext::from_window(*window, cx).into_mut();
|
||||
cx.run_until_parked();
|
||||
let workspace = window
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
(workspace, cx)
|
||||
}
|
||||
|
||||
pub async fn build_test_workspace<'a>(
|
||||
&'a self,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
|
||||
let project = self.build_test_project(cx).await;
|
||||
let app_state = self.app_state.clone();
|
||||
let window = cx.add_window(|window, cx| {
|
||||
window.activate_window();
|
||||
let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
|
||||
MultiWorkspace::new(workspace, window, cx)
|
||||
});
|
||||
let cx = VisualTestContext::from_window(*window, cx).into_mut();
|
||||
let workspace = window
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
(workspace, cx)
|
||||
}
|
||||
|
||||
pub fn active_workspace<'a>(
|
||||
&'a self,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Workspace>, &'a mut VisualTestContext) {
|
||||
let window = cx.update(|cx| {
|
||||
cx.active_window()
|
||||
.unwrap()
|
||||
.downcast::<MultiWorkspace>()
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
let entity = window
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
|
||||
// it might be nice to try and cleanup these at the end of each test.
|
||||
(entity, cx)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_channel_notes(
|
||||
channel_id: ChannelId,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> Task<anyhow::Result<Entity<ChannelView>>> {
|
||||
let window = cx.update(|_, cx| {
|
||||
cx.active_window()
|
||||
.unwrap()
|
||||
.downcast::<MultiWorkspace>()
|
||||
.unwrap()
|
||||
});
|
||||
let entity = window
|
||||
.read_with(cx, |mw, _| mw.workspace().clone())
|
||||
.unwrap();
|
||||
|
||||
cx.update(|window, cx| ChannelView::open(channel_id, None, entity.clone(), window, cx))
|
||||
}
|
||||
|
||||
impl Drop for TestClient {
|
||||
fn drop(&mut self) {
|
||||
self.app_state.client.teardown();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user