Files
shell-rs/tests/ui/connection.rs

1358 lines
47 KiB
Rust

//! Connecting a host: login questions, its connection state, the tabs it
//! opens, and the system and latency found on the way.
use crate::support::*;
#[derive(Clone, Copy)]
enum PromptBehavior {
Authentication,
UnknownHost,
HostKeyChanged,
}
struct PromptTerminalFactory {
behavior: PromptBehavior,
accepted: Arc<AtomicUsize>,
canceled: Arc<AtomicUsize>,
}
impl PromptTerminalFactory {
fn new(behavior: PromptBehavior) -> Self {
Self {
behavior,
accepted: Arc::new(AtomicUsize::new(0)),
canceled: Arc::new(AtomicUsize::new(0)),
}
}
}
impl TerminalTransportFactory for PromptTerminalFactory {
fn create(&self) -> Box<dyn TerminalTransport> {
Box::new(PromptTerminalTransport {
behavior: self.behavior,
accepted: self.accepted.clone(),
canceled: self.canceled.clone(),
})
}
}
struct PromptTerminalTransport {
behavior: PromptBehavior,
accepted: Arc<AtomicUsize>,
canceled: Arc<AtomicUsize>,
}
impl TerminalTransport for PromptTerminalTransport {
fn run(
self: Box<Self>,
_: TerminalSize,
commands: mpsc::Receiver<TerminalTransportCommand>,
events: async_channel::Sender<TerminalTransportEvent>,
) -> anyhow::Result<()> {
let request_id = 9001;
let kind = match self.behavior {
PromptBehavior::Authentication => ConnectionPromptKind::authentication(
"SSH 登录",
"请输入密码",
vec![ConnectionPromptField::new("密码", false)],
),
PromptBehavior::UnknownHost => ConnectionPromptKind::unknown_host(
"example.test",
22,
"ssh-ed25519",
"SHA256:test-fingerprint",
),
PromptBehavior::HostKeyChanged => ConnectionPromptKind::host_key_changed(
"example.test",
22,
"ssh-ed25519",
vec!["SHA256:old-fingerprint".into()],
"SHA256:new-fingerprint",
),
};
events.send_blocking(TerminalTransportEvent::Prompt(ConnectionPrompt::new(
request_id, kind,
)))?;
loop {
match commands.recv()? {
TerminalTransportCommand::PromptReply {
request_id: reply_id,
reply,
} if reply_id == request_id => match reply {
ConnectionPromptReply::TrustAndSave
if matches!(
self.behavior,
PromptBehavior::UnknownHost | PromptBehavior::HostKeyChanged
) =>
{
self.accepted.fetch_add(1, Ordering::SeqCst);
break;
}
ConnectionPromptReply::Answers(answers)
if matches!(self.behavior, PromptBehavior::Authentication)
&& answers.len() == 1 =>
{
self.accepted.fetch_add(1, Ordering::SeqCst);
break;
}
ConnectionPromptReply::Cancel => {
self.canceled.fetch_add(1, Ordering::SeqCst);
events
.send_blocking(TerminalTransportEvent::Failed("认证已取消".into()))?;
return Ok(());
}
_ => {}
},
TerminalTransportCommand::Shutdown => return Ok(()),
_ => {}
}
}
events.send_blocking(TerminalTransportEvent::Started)?;
while let Ok(command) = commands.recv() {
if matches!(command, TerminalTransportCommand::Shutdown) {
break;
}
}
Ok(())
}
}
#[gpui_kit::test]
async fn authentication_prompt_is_masked_and_drives_connected_state(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication));
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("ssh-auth-submit").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("status-connection", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(
window.try_find("ssh-auth-submit").is_some(),
"点击对话框外部不应取消认证"
);
})
.unwrap();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window
.within(("terminal-tab", 1u64))
.find("connection-status")
.label(),
Some("连接中")
);
let password = window.find(("ssh-auth-answer", 0usize));
assert_eq!(
password.value(),
None,
"masked input must not expose its value"
);
window.click(("ssh-auth-answer", 0usize), cx);
window.input("test-password", cx);
window.click("ssh-auth-submit", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 prompt-host")
&& window
.within(("terminal-tab", 1u64))
.try_find("connection-status")
.is_none()
})
.await;
assert_eq!(factory.accepted.load(Ordering::SeqCst), 1);
assert!(workspace.read_with(cx, |workspace, cx| {
workspace
.store()
.read(cx)
.host(id)
.unwrap()
.state
.is_connected()
}));
}
#[gpui_kit::test]
async fn a_changed_host_key_is_updated_from_its_question(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::HostKeyChanged));
let (handle, _) = open_workspace_with_remote_factory(cx, store, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("ok").is_some()
})
.await;
// Two ways out: keep the saved key, or update it and go on.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find("ok").label(), Some("更新并连接"));
assert!(window.try_find("cancel").is_some());
window.click("ok", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 prompt-host")
})
.await;
assert_eq!(factory.accepted.load(Ordering::SeqCst), 1);
assert_eq!(factory.canceled.load(Ordering::SeqCst), 0);
}
#[gpui_kit::test]
async fn canceling_unknown_host_prompt_keeps_host_disconnected(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::UnknownHost));
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("cancel").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("status-connection", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(
window.try_find("cancel").is_some(),
"点击对话框外部不应取消主机指纹确认"
);
})
.unwrap();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("cancel", cx);
})
.unwrap();
// The window says so at once; the transport hears of the cancel on its
// own thread, a moment later.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("未连接 prompt-host")
&& window
.within(("terminal-tab", 1u64))
.find("connection-status")
.label()
== Some("未连接")
&& factory.canceled.load(Ordering::SeqCst) == 1
})
.await;
assert_eq!(factory.accepted.load(Ordering::SeqCst), 0);
assert_eq!(factory.canceled.load(Ordering::SeqCst), 1);
assert!(!workspace.read_with(cx, |workspace, cx| {
workspace
.store()
.read(cx)
.host(id)
.unwrap()
.state
.is_connected()
}));
// The failed terminal tab remains visible, but connecting the host
// again must start a fresh transport instead of only activating that tab.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("cancel").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("cancel", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
factory.canceled.load(Ordering::SeqCst) == 2
})
.await;
// The context-menu item dispatches this same command, so it must also
// restart the exited transport instead of merely activating the tab.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(ConnectHost(id)), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("cancel").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("cancel", cx);
})
.unwrap();
}
#[gpui_kit::test]
async fn opening_sftp_updates_connection_state_without_terminal(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let (handle, workspace) = open_workspace_with_store(cx, store);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(OpenExplorer(id)), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
workspace.read(cx).store().read(cx).host(id).unwrap().state == ConnectionState::Connected
})
.await;
cx.update(|cx| {
let workspace = workspace.read(cx);
assert_eq!(workspace.explorers_of(id, cx).len(), 1);
assert_eq!(
workspace.store().read(cx).host(id).unwrap().state,
shellrs::host::ConnectionState::Connected
);
});
}
#[gpui_kit::test]
async fn double_click_on_host_opens_terminal_and_updates_status(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// Hosts seeded as connected already have terminal tabs.
assert!(window.find(("terminal", INITIAL_WEB_TERMINAL)).visible());
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
window
.within("host-tree")
.double_click(("host-row", DB_01), cx);
})
.unwrap();
cx.run_until_parked();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 db-01")
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
assert_eq!(
window.find("status-connection").label(),
Some("已连接 db-01")
);
})
.unwrap();
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.terminal(HostId(DB_01), cx).is_some());
let store = workspace.store().read(cx);
assert!(store.host(HostId(DB_01)).unwrap().state.is_connected());
});
}
#[gpui_kit::test]
async fn connected_host_opens_an_independent_terminal_each_time(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
factory.starts() == 1
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
factory.starts() == 2
})
.await;
// The right-click menu's “连接” item dispatches this same action.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(ConnectHost(id)), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
factory.starts() == 3
&& workspace
.read(cx)
.store()
.read(cx)
.host(id)
.unwrap()
.state
.is_connected()
})
.await;
cx.update(|cx| {
let workspace = workspace.read(cx);
assert_eq!(workspace.terminal_count(id, cx), 3);
assert!(workspace.remote_terminal(RemoteTerminalId(1)).is_some());
assert!(workspace.remote_terminal(RemoteTerminalId(2)).is_some());
assert!(workspace.remote_terminal(RemoteTerminalId(3)).is_some());
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("close-terminal", 3_u64), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
let workspace = workspace.read(cx);
workspace.terminal_count(id, cx) == 2
&& workspace
.store()
.read(cx)
.host(id)
.unwrap()
.state
.is_connected()
})
.await;
cx.update(|cx| {
let workspace = workspace.read(cx);
assert_eq!(workspace.terminal_count(id, cx), 2);
assert!(
workspace
.store()
.read(cx)
.host(id)
.unwrap()
.state
.is_connected()
);
});
}
#[gpui_kit::test]
async fn disconnecting_one_tab_leaves_the_other_tabs_of_its_host(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let second = RemoteTerminalId(FIRST_NEW_TERMINAL);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(ConnectHost(HostId(WEB_01))), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
remote_lifecycle(&workspace, second, cx) == TerminalLifecycle::Running
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// Connected tabs carry no dot.
for id in [INITIAL_WEB_TERMINAL, second.0] {
assert!(
window
.within(("terminal-tab", id))
.try_find("connection-status")
.is_none()
);
}
window.dispatch_action(Box::new(DisconnectTerminal(second)), cx);
})
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
assert!(!remote_lifecycle(&workspace, second, cx).accepts_input());
assert_eq!(
remote_lifecycle(&workspace, RemoteTerminalId(INITIAL_WEB_TERMINAL), cx),
TerminalLifecycle::Running
);
let workspace = workspace.read(cx);
let store = workspace.store().read(cx);
assert_eq!(
store.host(HostId(WEB_01)).unwrap().state,
ConnectionState::Connected
);
let screen = workspace
.remote_terminal(second)
.unwrap()
.read(cx)
.terminal()
.read(cx)
.screen_text(cx);
assert!(screen.contains("已断开连接"));
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(
window
.within(("terminal-tab", INITIAL_WEB_TERMINAL))
.try_find("connection-status")
.is_none()
);
let tab = window.within(("terminal-tab", second.0));
assert_eq!(tab.find("connection-status").label(), Some("未连接"));
let mark = tab.find(("terminal-tab-os", second.0)).bounds();
let dot = tab.find("connection-status").bounds();
let title = window.find(("terminal-tab", second.0)).bounds();
let close = window.find(("close-terminal", second.0)).bounds();
assert_eq!(mark.center().y, close.center().y);
let centers = dot.center() - mark.center();
let distance = (f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt();
assert!(distance > f32::from(mark.size.width + dot.size.width) / 2.);
assert!(dot.right() < close.left());
assert_eq!(mark.center().y, title.center().y);
window.click(("terminal", second.0), cx);
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
remote_lifecycle(&workspace, second, cx) == TerminalLifecycle::Running
&& window
.within(("terminal-tab", second.0))
.try_find("connection-status")
.is_none()
})
.await;
}
#[gpui_kit::test]
async fn failed_connection_tab_reports_disconnected_then_recovers(cx: &mut TestAppContext) {
let (store, host_id) = one_host_store(AuthKind::Password);
let factory = Arc::new(FakeTerminalFactory::fail_first());
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory);
let terminal_id = RemoteTerminalId(1);
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(ConnectHost(host_id)), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
matches!(
remote_lifecycle(&workspace, terminal_id, cx),
TerminalLifecycle::Failed(_)
) && window
.within(("terminal-tab", terminal_id.0))
.find("connection-status")
.label()
== Some("未连接")
})
.await;
in_frame(cx, handle, |window, cx| {
window.dispatch_action(Box::new(ReconnectTerminal(terminal_id)), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
remote_lifecycle(&workspace, terminal_id, cx) == TerminalLifecycle::Running
&& window
.within(("terminal-tab", terminal_id.0))
.try_find("connection-status")
.is_none()
})
.await;
}
#[gpui_kit::test]
async fn connecting_marks_the_host_with_the_host_operating_system(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let factory = Arc::new(FakeTerminalFactory::reports_os(HostOs::Fedora));
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(("host-os", id.0)).label(), Some("未探测到系统"));
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
// The engine batches transport events on a 16ms timer, so the mark
// changes a frame or two after the tab opens.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find(("host-os", id.0)).label() == Some("Fedora")
})
.await;
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
assert_eq!(store.host(id).unwrap().os, Some(HostOs::Fedora));
});
}
#[gpui_kit::test]
async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestAppContext) {
let (store, id) = one_host_store(AuthKind::Password);
let factory = Arc::new(FakeTerminalFactory::reports_latency(Latency::Measured(
Duration::from_millis(32),
)));
let (handle, _) = open_workspace_with_remote_factory(cx, store, factory);
// The first terminal of a store with nothing connected.
let terminal = 1_u64;
let latency = ("terminal-latency", terminal);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", id.0), cx);
})
.unwrap();
// Transport events reach the engine on its 16ms batching timer.
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window
.try_find(latency)
.is_some_and(|element| element.label() == Some("32 ms"))
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("close-terminal", terminal), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find(latency).is_none());
})
.unwrap();
}
/// The first terminal and the first host of an empty store, which a link
/// opens.
const LINK_TERMINAL: u64 = 1;
const LINK_HOST: HostId = HostId(1);
fn open_link(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
workspace: &Entity<Workspace>,
url: &str,
tab: Option<&str>,
) {
let link = shellrs::cli::OpenLink {
url: url.into(),
tab: tab.map(str::to_string),
};
cx.update_window(handle.into(), |_, window, cx| {
workspace.update(cx, |workspace, cx| workspace.open_link(link, window, cx));
})
.unwrap();
cx.run_until_parked();
}
#[gpui_kit::test]
async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut TestAppContext) {
use shellrs::app::{CloseExplorer, CloseTerminal};
use shellrs::host::BookmarkSide;
let data = tempfile::tempdir().unwrap();
let path = data.path().join("shellrs.db");
let keychain = Arc::new(InMemorySecretStore::default());
let store = HostStore::load(HostDatabase::open(&path).unwrap())
.unwrap()
.with_secrets(keychain.clone());
let remote = Arc::new(RecordingRemoteProvider::default());
let (handle, workspace) = open_workspace_with_credentials(
cx,
store,
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
open_link(
cx,
handle,
&workspace,
"ssh://token:p%40ss@10.0.0.9:2222",
Some("db-prod"),
);
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 db-prod")
})
.await;
let login = remote.logins().pop().expect("connected");
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "token")
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("terminal-tab", LINK_TERMINAL)).label(),
Some("db-prod")
);
// Not saved, and across the window: the hosts made way.
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
assert_eq!(window.find("show-hosts").checked(), Some(false));
assert!(window.try_find("host-search").is_none());
// The bastion host gets the terminal alone: of the tools, only the
// snippets, which type into it.
assert!(window.try_find("tool-snippets").is_some());
for tool in ["tool-history", "tool-docker", "tool-monitor"] {
assert!(window.try_find(tool).is_none(), "{tool}");
}
})
.unwrap();
assert!(login.shell_only);
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_temporary(LINK_HOST));
assert!(store.hosts().is_empty());
assert!(store.recent_hosts().next().is_none());
// The link's password is in memory, not in the keychain.
assert_eq!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.as_deref()
.map(String::as_str),
Some("p@ss")
);
});
assert!(keychain.is_empty());
// There is nothing to edit.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(EditHost(LINK_HOST)), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find("host-name").is_none());
})
.unwrap();
// Its SFTP tab works, bookmarks and all, without writing to disk.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(OpenExplorer(LINK_HOST)), cx);
})
.unwrap();
cx.run_until_parked();
store.update(cx, |store, cx| {
assert!(store.add_bookmark(LINK_HOST, BookmarkSide::Remote, "/var/log", cx));
});
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.notifications(cx).is_empty());
})
.unwrap();
// The host stays while a tab of it does.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
})
.unwrap();
cx.run_until_parked();
assert!(store.read_with(cx, |store, _| store.host(LINK_HOST).is_some()));
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx);
})
.unwrap();
cx.run_until_parked();
store.read_with(cx, |store, _| {
assert!(store.host(LINK_HOST).is_none());
assert!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.is_none()
);
});
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
assert!(saved.hosts().is_empty());
assert!(saved.recent_hosts().next().is_none());
assert!(saved.bookmarks(LINK_HOST, BookmarkSide::Remote).is_empty());
}
#[gpui_kit::test]
async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContext) {
use shellrs::app::CloseExplorer;
let remote = Arc::new(RecordingRemoteProvider::default());
let (handle, workspace) = open_workspace_with_credentials(
cx,
HostStore::empty(),
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
// How JumpServer opens WinSCP: the link alone.
open_link(
cx,
handle,
&workspace,
"sftp://token:p%40ss@10.0.0.9:2222",
None,
);
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 10.0.0.9")
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("explorer-tab", SFTP_TAB)).label(),
Some("10.0.0.9 · SFTP")
);
// An SFTP tab alone, across the window.
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
assert_eq!(window.find("show-hosts").checked(), Some(false));
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
})
.unwrap();
assert!(remote.logins().is_empty());
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_external(LINK_HOST));
let login = store.login(LINK_HOST).unwrap();
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "token")
);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
});
// It goes with its tab.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx);
})
.unwrap();
cx.run_until_parked();
store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none()));
}
/// The title bar's 临时连接: a host's login without its group, route or
/// notes, connected to with every feature and saved nowhere.
#[gpui_kit::test]
async fn a_temporary_connection_has_every_feature_and_saves_nothing(cx: &mut TestAppContext) {
use shellrs::app::CloseTerminal;
let data = tempfile::tempdir().unwrap();
let path = data.path().join("shellrs.db");
let keychain = Arc::new(InMemorySecretStore::default());
let store = HostStore::load(HostDatabase::open(&path).unwrap())
.unwrap()
.with_secrets(keychain.clone());
let remote = Arc::new(RecordingRemoteProvider::default());
let tester = Arc::new(FakeConnectionTester::default());
let (handle, workspace) =
open_workspace_with_credentials(cx, store, remote.clone(), tester.clone());
in_frame(cx, handle, |window, cx| {
window.click("temporary-connection", cx)
});
in_frame(cx, handle, |window, cx| {
// Only what a login needs.
assert!(window.try_find("host-route").is_none());
assert!(window.try_find("host-notes").is_none());
assert!(window.try_find("test-connection").is_some());
assert_eq!(
window.find("temporary-note").label(),
Some("仅当前使用的临时会话,不会保存到主机列表。")
);
// The name may be left out.
window.click("host-address", cx);
window.input("10.0.0.9", cx);
window.click("host-port", cx);
window.press("secondary-a", cx);
window.input("2222", cx);
window.click("host-user", cx);
window.press("secondary-a", cx);
window.input("deploy", cx);
window.click("host-password", cx);
window.input("p@ss", cx);
window.click("test-connection", cx);
});
// 测试连接 tries it as it would connect: directly.
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
window.render_frame(cx);
window.notifications(cx).len() == 1
})
.await;
assert_eq!(tester.routes(), [(LoginRoute::Direct, None)]);
in_frame(cx, handle, |window, cx| window.click("commit", cx));
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 10.0.0.9")
})
.await;
let login = remote.logins().pop().expect("connected");
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "deploy")
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
// Every channel it likes, unlike a bastion host's link.
assert!(!login.shell_only);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("terminal-tab", LINK_TERMINAL)).label(),
Some("10.0.0.9")
);
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
// The hosts stay where they were.
assert_eq!(window.find("show-hosts").checked(), Some(true));
for tool in [
"tool-snippets",
"tool-history",
"tool-docker",
"tool-monitor",
] {
assert!(window.try_find(tool).is_some(), "{tool}");
}
})
.unwrap();
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_temporary(LINK_HOST));
assert!(!store.is_external(LINK_HOST));
assert!(store.hosts().is_empty());
assert!(store.recent_hosts().next().is_none());
assert_eq!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.as_deref()
.map(String::as_str),
Some("p@ss")
);
});
assert!(keychain.is_empty());
// It goes with its tab, password and all.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
})
.unwrap();
cx.run_until_parked();
store.read_with(cx, |store, _| {
assert!(store.host(LINK_HOST).is_none());
assert!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.is_none()
);
});
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
assert!(saved.hosts().is_empty());
}
#[gpui_kit::test]
async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_credentials(
cx,
HostStore::empty(),
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
open_link(cx, handle, &workspace, "ssh://10.0.0.9:2222", None);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.notifications(cx).len(), 1);
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
// Nothing opened, so the hosts stay.
assert_eq!(window.find("show-hosts").checked(), Some(true));
})
.unwrap();
workspace.read_with(cx, |workspace, cx| {
assert!(workspace.store().read(cx).host(LINK_HOST).is_none());
});
}
/// Initial errors and later exits both retain their tab and retry through
/// that tab's regular reconnect action, without leaking Enter to the shell.
async fn enter_reconnects_stopped_remote(
cx: &mut TestAppContext,
factory: Arc<FakeTerminalFactory>,
) {
let (store, host) = one_host_store(AuthKind::Password);
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
let terminal = RemoteTerminalId(1);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", host.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
matches!(
remote_lifecycle(&workspace, terminal, cx),
TerminalLifecycle::Failed(_) | TerminalLifecycle::Exited { .. }
)
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
let screen = workspace
.read(cx)
.remote_terminal(terminal)
.unwrap()
.read(cx)
.terminal()
.read(cx)
.screen_text(cx);
assert!(screen.contains("按 Enter 重新连接"), "{screen}");
window.click(("terminal", terminal.0), cx);
for key in ["shift-enter", "ctrl-enter", "alt-enter"] {
window.press(key, cx);
}
cx.write_to_clipboard(ClipboardItem::new_string("\n".into()));
window.dispatch_action(Box::new(shellrs::app::PasteTerminal), cx);
window.dispatch_action(Box::new(FindInTerminal), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("terminal-find", cx);
window.press("enter", cx);
})
.unwrap();
cx.run_until_parked();
assert_eq!(
factory.starts(),
1,
"modifiers, paste and find must not reconnect"
);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("terminal", terminal.0), cx);
window.press("enter", cx);
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, cx| {
remote_lifecycle(&workspace, terminal, cx) == TerminalLifecycle::Running
})
.await;
assert_eq!(factory.starts(), 2);
assert_eq!(
factory.written_text(),
"",
"reconnect Enter must not reach the new shell"
);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(("terminal", terminal.0)).focused(), Some(true));
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |_, _| {
factory.written_text() == "\r"
})
.await;
assert_eq!(factory.starts(), 2, "connected Enter is ordinary input");
}
#[gpui_kit::test]
async fn enter_reconnects_failed_remote(cx: &mut TestAppContext) {
enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::fail_first())).await;
}
#[gpui_kit::test]
async fn enter_reconnects_exited_remote(cx: &mut TestAppContext) {
enter_reconnects_stopped_remote(cx, Arc::new(FakeTerminalFactory::exit_first())).await;
}
#[gpui_kit::test]
async fn enter_retries_canceled_authentication_in_the_same_tab(cx: &mut TestAppContext) {
let (store, host) = one_host_store(AuthKind::Password);
let factory = Arc::new(PromptTerminalFactory::new(PromptBehavior::Authentication));
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", host.0), cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("ssh-auth-submit").is_some()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("escape", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
matches!(
remote_lifecycle(&workspace, RemoteTerminalId(1), cx),
TerminalLifecycle::Exited { .. }
) && window.try_find("ssh-auth-submit").is_none()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("terminal", 1_u64), cx);
window.press("enter", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find("ssh-auth-submit").is_some()
})
.await;
cx.update(|cx| {
assert_eq!(
remote_lifecycle(&workspace, RemoteTerminalId(1), cx),
TerminalLifecycle::Starting
);
assert!(
workspace
.read(cx)
.remote_terminal(RemoteTerminalId(2))
.is_none()
);
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("escape", cx);
})
.unwrap();
cx.run_until_parked();
}
/// Exercise the production title in the same TabBar used by the dock, with
/// and without its badge. Geometry must be invariant under state/selection.
struct TabStatusLayout {
state: ConnectionState,
selected: usize,
rem: f32,
}
impl gpui_kit::Render for TabStatusLayout {
fn render(
&mut self,
window: &mut gpui_kit::Window,
_: &mut gpui_kit::Context<Self>,
) -> impl gpui_kit::IntoElement {
use gpui_kit::component::{
Sizable as _,
tab::{Tab, TabBar},
};
use gpui_kit::{InteractiveElement as _, ParentElement as _};
use shellrs::shared::{ClosableTabTitle, HostMark};
window.set_rem_size(px(self.rem));
let title = |id| {
ClosableTabTitle::new(
("layout-title", id),
HostMark::new(("layout-mark", id), "host", Some(HostOs::Ubuntu)).small(),
"host",
)
.closable(("layout-close", id), Box::new(shellrs::app::CloseActiveTab))
};
TabBar::new("layout-tabs")
.selected_index(self.selected)
.child(
Tab::new()
.debug_selector(|| "plain-tab".into())
.child(title(0u64)),
)
.child(
Tab::new()
.debug_selector(|| "status-tab".into())
.child(title(1u64).connection_state(self.state)),
)
}
}
#[gpui_kit::test]
fn connection_badge_preserves_icon_alignment_and_fits_the_tab(cx: &mut TestAppContext) {
init_app(cx);
let (view, cx) = cx.add_window_view(|_, _| TabStatusLayout {
state: ConnectionState::Connected,
selected: 0,
rem: 16.,
});
// The headless platform fixes device scaling at 2x; vary rem sizing too.
for rem in [12., 14., 16., 18.] {
for selected in [0, 1] {
for state in [
ConnectionState::Connected,
ConnectionState::Connecting,
ConnectionState::Disconnected,
] {
view.update(cx, |view, cx| {
view.rem = rem;
view.selected = selected;
view.state = state;
cx.notify();
});
cx.run_until_parked();
cx.update(|window, cx| window.render_frame(cx));
let clipped_tab = cx.debug_bounds("status-tab").unwrap();
cx.update(|window, _| {
let plain = window.find(("layout-title", 0u64)).bounds();
let title = window.find(("layout-title", 1u64)).bounds();
let plain_mark = window.find(("layout-mark", 0u64)).bounds();
let mark = window.find(("layout-mark", 1u64)).bounds();
let close = window.find(("layout-close", 1u64)).bounds();
assert_eq!(title.size, plain.size, "badge must not affect title layout");
assert_eq!(mark.size, plain_mark.size, "badge must not shrink the icon");
assert_eq!(
mark.center() - title.origin,
plain_mark.center() - plain.origin
);
assert_eq!(mark.center().y, close.center().y);
assert_eq!(mark.center().y, title.center().y);
// A connection that is up is the normal state: no dot.
let dot = window
.within(("layout-title", 1u64))
.try_find("connection-status");
assert_eq!(dot.is_none(), state == ConnectionState::Connected);
let Some(dot) = dot else {
return;
};
let dot = dot.bounds();
let centers = dot.center() - mark.center();
let distance =
(f32::from(centers.x).powi(2) + f32::from(centers.y).powi(2)).sqrt();
assert!(
distance > f32::from(mark.size.width + dot.size.width) / 2.,
"circles must not overlap"
);
assert!(
dot.bottom() <= clipped_tab.bottom() - px(1.),
"badge must fit the dock tab"
);
assert!(
dot.right() < mark.right() + px(rem / 4.),
"badge must stop before the label"
);
assert!(dot.right() < close.left());
});
}
}
}
}
#[gpui_kit::test]
async fn reconnect_toolbar_works_without_window_focus(cx: &mut TestAppContext) {
let (store, host) = one_host_store(AuthKind::Password);
let factory = Arc::new(FakeTerminalFactory::default());
let (handle, workspace) = open_workspace_with_remote_factory(cx, store, factory.clone());
in_frame(cx, handle, |window, cx| {
window
.within("host-tree")
.double_click(("host-row", host.0), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
workspace
.read(cx)
.remote_terminal(RemoteTerminalId(1))
.unwrap()
.read(cx)
.lifecycle(cx)
== TerminalLifecycle::Running
})
.await;
let starts = factory.starts();
in_frame(cx, handle, |window, cx| window.blur(cx));
in_frame(cx, handle, |window, cx| {
assert!(window.focused(cx).is_none());
window.click(("reconnect", 1u64), cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
factory.starts() > starts
&& workspace
.read(cx)
.remote_terminal(RemoteTerminalId(1))
.unwrap()
.read(cx)
.lifecycle(cx)
== TerminalLifecycle::Running
})
.await;
assert_eq!(factory.starts(), starts + 1);
}