- gpui-base 的对话框按 10 + 层号的优先级绘制,通知层原来是 1,一直被对话框背景盖住、 发灰,点它也点到背景上;改为 POPUP_PRIORITY - 1,高过对话框、低于菜单和 tooltip - 主机表单的错误通知点一下就收起 - 新增测试:点主机对话框的错误通知能点到它并收起(旧优先级下这个测试超时) - CLAUDE.md 更正对话框绘制优先级的说法
975 lines
35 KiB
Rust
975 lines
35 KiB
Rust
//! The host dialog: authentication, 连接方式 (jump hosts and proxies),
|
|
//! 备注, passwords in the keychain and 测试连接.
|
|
|
|
use crate::support::*;
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
|
|
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
// A new host logs in with a password.
|
|
let sources = window.within("host-auth-source");
|
|
assert_eq!(sources.find(0usize).selected(), Some(true));
|
|
assert!(window.find("host-password").visible());
|
|
assert!(window.try_find("host-no-password-note").is_none());
|
|
window.click("host-name", cx);
|
|
window.input("box", cx);
|
|
window.click("host-address", cx);
|
|
window.input("10.0.0.9", cx);
|
|
window.within("host-auth-source").click(2usize, cx);
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert!(window.find("host-user").visible());
|
|
assert!(window.try_find("host-password").is_none());
|
|
assert!(window.try_find("host-credential").is_none());
|
|
assert_eq!(
|
|
window.find("host-no-password-note").label(),
|
|
Some("依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。")
|
|
);
|
|
window.click("commit", cx);
|
|
});
|
|
wait_for_dialog_to_close(cx, handle).await;
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
let host = &store.hosts()[0];
|
|
assert_eq!(host.auth, AuthKind::NoPassword);
|
|
assert_eq!(host.credential, None);
|
|
assert_eq!(
|
|
store.login(host.id).unwrap().method,
|
|
LoginMethod::NoPassword
|
|
);
|
|
});
|
|
}
|
|
|
|
/// A store with three hosts to jump through or to, in this order:
|
|
/// 阿里云99, 禅道 and 内网库.
|
|
fn store_with_jump_hosts() -> (HostStore, [HostId; 3]) {
|
|
let mut store = HostStore::empty();
|
|
let ids = [
|
|
("阿里云99", "120.25.220.186"),
|
|
("禅道", "8.138.95.125"),
|
|
("内网库", "10.0.0.5"),
|
|
]
|
|
.map(|(name, host)| {
|
|
store.insert_unnotified(HostDraft::new(
|
|
name,
|
|
host,
|
|
22,
|
|
"root",
|
|
AuthKind::Password,
|
|
None,
|
|
))
|
|
});
|
|
(store, ids)
|
|
}
|
|
|
|
/// Pick the host called `name` with the jump-host picker, the way a person
|
|
/// with many hosts would: by searching for it.
|
|
fn add_jump_host(cx: &mut TestAppContext, handle: WindowHandle<Root>, name: &str) {
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("jump-add-trigger", cx)
|
|
});
|
|
in_frame(cx, handle, |window, cx| window.input(name, cx));
|
|
in_frame(cx, handle, |window, cx| window.press("enter", cx));
|
|
}
|
|
|
|
fn chain(window: &mut gpui_kit::Window) -> Option<String> {
|
|
window.find("host-route-chain").label().map(str::to_string)
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_host_goes_through_the_jump_hosts_it_lists_in_order(cx: &mut TestAppContext) {
|
|
let (store, [aliyun, zentao, _]) = store_with_jump_hosts();
|
|
let (handle, workspace) = open_workspace_with_store(cx, store);
|
|
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
// A new host connects directly, with nothing more to fill in.
|
|
let routes = window.within("host-route");
|
|
assert_eq!(routes.find(0usize).selected(), Some(true));
|
|
assert!(window.try_find("host-route-chain").is_none());
|
|
window.click("host-name", cx);
|
|
window.input("db", cx);
|
|
window.click("host-address", cx);
|
|
window.input("10.0.9.9", cx);
|
|
window.within("host-route").click(1usize, cx);
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(chain(window).as_deref(), Some("本机 → 当前主机"));
|
|
assert_eq!(
|
|
window.find("host-route-note").label(),
|
|
Some("依次经过跳板主机连接到当前主机,可添加多台。")
|
|
);
|
|
window.click("commit", cx);
|
|
});
|
|
in_frame(cx, handle, |window, _| {
|
|
assert_eq!(window.find("form-error").label(), Some("请添加跳板主机"));
|
|
});
|
|
|
|
add_jump_host(cx, handle, "阿里云");
|
|
add_jump_host(cx, handle, "禅道");
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(
|
|
chain(window).as_deref(),
|
|
Some("本机 → 阿里云99 → 禅道 → 当前主机")
|
|
);
|
|
assert_eq!(
|
|
window.find(("jump-hop", aliyun.0)).label(),
|
|
Some("阿里云99")
|
|
);
|
|
assert!(window.find(("jump-hop", zentao.0)).visible());
|
|
// Taking the first one off and adding it back puts it last.
|
|
window.click(("remove-jump-hop", 0usize), cx);
|
|
});
|
|
in_frame(cx, handle, |window, _| {
|
|
assert_eq!(chain(window).as_deref(), Some("本机 → 禅道 → 当前主机"));
|
|
assert!(window.try_find(("jump-hop", aliyun.0)).is_none());
|
|
});
|
|
// Searching by address finds it too.
|
|
add_jump_host(cx, handle, "120.25");
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(
|
|
chain(window).as_deref(),
|
|
Some("本机 → 禅道 → 阿里云99 → 当前主机")
|
|
);
|
|
// Off the picker first: a focused picker opens on the commit.
|
|
window.click("host-name", cx);
|
|
window.click("commit", cx);
|
|
});
|
|
wait_for_dialog_to_close(cx, handle).await;
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
let created = store.hosts().last().unwrap();
|
|
assert_eq!(created.name.as_ref(), "db");
|
|
assert_eq!(created.route, Route::Jump(vec![Some(zentao), Some(aliyun)]));
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_deleted_jump_host_keeps_its_place_until_it_is_removed(cx: &mut TestAppContext) {
|
|
let (mut store, [aliyun, zentao, inner]) = store_with_jump_hosts();
|
|
store.update_unnotified(
|
|
inner,
|
|
HostDraft::new("内网库", "10.0.0.5", 22, "root", AuthKind::Password, None)
|
|
.with_route(Route::Jump(vec![Some(aliyun)])),
|
|
);
|
|
store.remove_unnotified(aliyun);
|
|
let (handle, workspace) = open_workspace_with_store(cx, store);
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.dispatch_action(Box::new(EditHost(inner)), cx)
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(
|
|
window.within("host-route").find(1usize).selected(),
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
chain(window).as_deref(),
|
|
Some("本机 → 已删除的主机 → 当前主机")
|
|
);
|
|
assert_eq!(
|
|
window.find(("jump-hop-deleted", 0usize)).label(),
|
|
Some("已删除的主机")
|
|
);
|
|
window.click("commit", cx);
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(
|
|
window.find("form-error").label(),
|
|
Some("请移除已删除的跳板主机")
|
|
);
|
|
window.click(("remove-jump-hop", 0usize), cx);
|
|
});
|
|
add_jump_host(cx, handle, "禅道");
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(chain(window).as_deref(), Some("本机 → 禅道 → 当前主机"));
|
|
window.click("host-name", cx);
|
|
window.click("commit", cx);
|
|
});
|
|
wait_for_dialog_to_close(cx, handle).await;
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
assert_eq!(
|
|
store.host(inner).unwrap().route,
|
|
Route::Jump(vec![Some(zentao)])
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestAppContext) {
|
|
let (store, _) = store_with_jump_hosts();
|
|
let tester = Arc::new(FakeConnectionTester::default());
|
|
let (handle, _) = open_workspace_with_tester(cx, store, tester.clone());
|
|
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("host-name", cx);
|
|
window.input("db", cx);
|
|
window.click("host-address", cx);
|
|
window.input("10.0.9.9", cx);
|
|
window.within("host-route").click(1usize, cx);
|
|
});
|
|
add_jump_host(cx, handle, "阿里云");
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("host-name", cx);
|
|
window.click("test-connection", cx);
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).len() == 1
|
|
})
|
|
.await;
|
|
let routes = tester.routes();
|
|
let [(LoginRoute::Jump(hops), None)] = routes.as_slice() else {
|
|
panic!("not one test through a jump host: {routes:?}");
|
|
};
|
|
let [JumpLogin::Host { name, login }] = hops.as_slice() else {
|
|
panic!("not one jump host: {hops:?}");
|
|
};
|
|
assert_eq!(name, "阿里云99");
|
|
assert_eq!(
|
|
**login,
|
|
HostLogin::manual("120.25.220.186", 22, "root", AuthKind::Password)
|
|
);
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut TestAppContext) {
|
|
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
|
|
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("host-name", cx);
|
|
window.input("db", cx);
|
|
window.click("host-address", cx);
|
|
window.input("10.0.9.9", cx);
|
|
window.click("host-notes", cx);
|
|
window.input("机房 A", cx);
|
|
// Enter starts a new line; it does not submit the dialog.
|
|
window.press("enter", cx);
|
|
window.input("负责人:张三", cx);
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert!(window.find("commit").visible());
|
|
window.click("commit", cx);
|
|
});
|
|
wait_for_dialog_to_close(cx, handle).await;
|
|
let id = cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
let host = &store.hosts()[0];
|
|
assert_eq!(host.notes.as_ref(), "机房 A\n负责人:张三");
|
|
host.id
|
|
});
|
|
|
|
// Edited, the notes are there to change. The closed dialog took the
|
|
// focus with it, so the list takes it back first.
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click(("host-row", id.0), cx);
|
|
window.dispatch_action(Box::new(EditHost(id)), cx)
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("host-notes", cx);
|
|
window.press("cmd-a", cx);
|
|
window.input("已下线", cx);
|
|
window.click("commit", cx);
|
|
});
|
|
wait_for_dialog_to_close(cx, handle).await;
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
assert_eq!(store.host(id).unwrap().notes.as_ref(), "已下线");
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn deleting_a_jump_host_leaves_the_connection_behind_it_alone(cx: &mut TestAppContext) {
|
|
let (mut store, [aliyun, _, inner]) = store_with_jump_hosts();
|
|
store.update_unnotified(
|
|
inner,
|
|
HostDraft::new("内网库", "10.0.0.5", 22, "root", AuthKind::Password, None)
|
|
.with_route(Route::Jump(vec![Some(aliyun)])),
|
|
);
|
|
let remote = Arc::new(RecordingRemoteProvider::default());
|
|
let (handle, workspace) = open_workspace_with_credentials(
|
|
cx,
|
|
store,
|
|
remote.clone(),
|
|
Arc::new(FakeConnectionTester::default()),
|
|
);
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.dispatch_action(Box::new(ConnectHost(inner)), cx)
|
|
});
|
|
// The terminal is given the way there, jump host and all.
|
|
let logins = remote.logins();
|
|
let [login] = logins.as_slice() else {
|
|
panic!("not one connection: {logins:?}");
|
|
};
|
|
assert!(
|
|
matches!(&login.route, LoginRoute::Jump(hops)
|
|
if matches!(hops.as_slice(), [JumpLogin::Host { name, .. }] if name == "阿里云99")),
|
|
"{:?}",
|
|
login.route
|
|
);
|
|
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.dispatch_action(Box::new(DeleteHost(aliyun)), cx)
|
|
});
|
|
in_frame(cx, handle, |window, cx| window.click("ok", cx));
|
|
cx.run_until_parked();
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
assert!(store.host(aliyun).is_none());
|
|
assert_eq!(store.host(inner).unwrap().route, Route::Jump(vec![None]));
|
|
});
|
|
// Not reconnected: that would only fail now.
|
|
assert_eq!(remote.logins().len(), 1);
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_host_behind_a_proxy_keeps_the_proxys_password_in_the_keychain(cx: &mut TestAppContext) {
|
|
let secrets = Arc::new(InMemorySecretStore::default());
|
|
let tester = Arc::new(FakeConnectionTester::default());
|
|
let (handle, workspace) = open_workspace_with_tester(
|
|
cx,
|
|
HostStore::empty().with_secrets(secrets.clone()),
|
|
tester.clone(),
|
|
);
|
|
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("host-name", cx);
|
|
window.input("abroad", cx);
|
|
window.click("host-address", cx);
|
|
window.input("203.0.113.7", cx);
|
|
window.within("host-route").click(2usize, cx);
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(window.find("host-proxy-kind").value(), Some("HTTP 代理"));
|
|
window.click("commit", cx);
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(window.find("form-error").label(), Some("请输入代理地址"));
|
|
window.within("host-proxy-kind").click("input", cx);
|
|
});
|
|
for key in ["down", "enter"] {
|
|
in_frame(cx, handle, |window, cx| window.press(key, cx));
|
|
}
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(window.find("host-proxy-kind").value(), Some("SOCKS5 代理"));
|
|
window.click("host-proxy-host", cx);
|
|
window.input("127.0.0.1", cx);
|
|
window.click("host-proxy-port", cx);
|
|
window.input("7890", cx);
|
|
window.click("host-proxy-password", cx);
|
|
window.input("hunter2", cx);
|
|
window.click("commit", cx);
|
|
});
|
|
in_frame(cx, handle, |window, cx| {
|
|
assert_eq!(
|
|
window.find("form-error").label(),
|
|
Some("填写代理密码时请同时填写用户名")
|
|
);
|
|
window.click("host-proxy-user", cx);
|
|
window.input("me", cx);
|
|
window.click("test-connection", cx);
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).len() == 1
|
|
})
|
|
.await;
|
|
let proxy = ProxySettings::new(ProxyKind::Socks5, "127.0.0.1", 7890).with_user("me");
|
|
// The test takes the password from the form, saved or not.
|
|
assert_eq!(
|
|
tester.routes(),
|
|
[(
|
|
LoginRoute::Proxy((&proxy).into()),
|
|
Some("hunter2".to_string())
|
|
)]
|
|
);
|
|
in_frame(cx, handle, |window, cx| window.click("commit", cx));
|
|
wait_for_dialog_to_close(cx, handle).await;
|
|
|
|
assert_eq!(
|
|
secrets
|
|
.get(&SecretRef::proxy("me", "127.0.0.1", 7890))
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
Some("hunter2")
|
|
);
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
assert_eq!(store.hosts()[0].route, Route::Proxy(proxy));
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn connection_edits_reconnect_once_but_display_edits_do_not(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
|
|
&& window.find("status-connection").label() == Some("已连接 prompt-host")
|
|
})
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().clone();
|
|
store.update(cx, |store, cx| {
|
|
let mut draft = store.host(id).unwrap().draft();
|
|
draft.address = "new.example.test".into();
|
|
assert!(store.update(id, draft, cx));
|
|
});
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
factory.starts() == 2
|
|
&& window.find("status-connection").label() == Some("已连接 prompt-host")
|
|
})
|
|
.await;
|
|
assert_eq!(factory.starts(), 2);
|
|
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().clone();
|
|
store.update(cx, |store, cx| {
|
|
let mut draft = store.host(id).unwrap().draft();
|
|
draft.name = "renamed".into();
|
|
assert!(store.update(id, draft, cx));
|
|
});
|
|
});
|
|
cx.run_until_parked();
|
|
assert_eq!(factory.starts(), 2);
|
|
}
|
|
|
|
/// The error is painted above the dialog and its backdrop, not under them:
|
|
/// a click on it reaches it, and puts it away, instead of landing on the
|
|
/// backdrop.
|
|
#[gpui_kit::test]
|
|
async fn a_form_error_sits_above_the_dialog_and_a_click_puts_it_away(cx: &mut TestAppContext) {
|
|
let (handle, _) = open_workspace(cx);
|
|
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
window.click("host-name", cx);
|
|
window.click("commit", cx);
|
|
});
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("form-error").is_some()
|
|
})
|
|
.await;
|
|
|
|
in_frame(cx, handle, |window, cx| window.click("form-error", cx));
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).is_empty()
|
|
})
|
|
.await;
|
|
in_frame(cx, handle, |window, _| {
|
|
assert!(window.find("commit").visible(), "the dialog stays open");
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) {
|
|
let (handle, workspace) = open_workspace(cx);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("new-host", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.find("commit").visible());
|
|
assert!(window.try_find("form-error").is_none());
|
|
|
|
// The address and its port share a row; the name above them and the
|
|
// user name below each take the row's whole width.
|
|
let [name, host, port, user] = ["host-name", "host-address", "host-port", "host-user"]
|
|
.map(|id| window.find(id).bounds());
|
|
assert_eq!(host.top(), port.top());
|
|
assert!(host.right() < port.left() && host.size.width > port.size.width);
|
|
assert!(name.bottom() < host.top() && user.top() > host.bottom());
|
|
assert_eq!((name.left(), name.right()), (host.left(), port.right()));
|
|
assert_eq!((user.left(), user.right()), (name.left(), name.right()));
|
|
|
|
// An empty form is rejected and the dialog stays open. The commit
|
|
// action is dispatched deferred, so the error shows after effects run.
|
|
// The dialog's focus trap owns focus until a field is clicked.
|
|
window.click("host-name", cx);
|
|
assert_eq!(window.find("host-name").focused(), Some(true));
|
|
window.click("commit", cx);
|
|
})
|
|
.unwrap();
|
|
// The commit action is dispatched deferred; wait for its error line.
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
|
window
|
|
.try_find("form-error")
|
|
.is_some_and(|error| error.visible())
|
|
})
|
|
.await;
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.find("commit").visible());
|
|
// The error pops up as a notification over the dialog, not as a line
|
|
// at the bottom of the form.
|
|
assert_eq!(window.notifications(cx).len(), 1);
|
|
assert_eq!(window.find("form-error").label(), Some("请输入名称"));
|
|
window.click("commit", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
// Trying again replaces it instead of stacking another.
|
|
assert_eq!(window.notifications(cx).len(), 1);
|
|
|
|
window.click("host-name", cx);
|
|
window.input("db-02", cx);
|
|
window.click("host-address", cx);
|
|
window.input("10.0.3.7", cx);
|
|
window.click("commit", cx);
|
|
})
|
|
.unwrap();
|
|
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
|
window.try_find("commit").is_none()
|
|
})
|
|
.await;
|
|
// The error was about the dialog, which is gone: so is the error.
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).is_empty() && window.try_find("form-error").is_none()
|
|
})
|
|
.await;
|
|
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
let created = store
|
|
.hosts()
|
|
.iter()
|
|
.find(|host| host.name == "db-02")
|
|
.expect("db-02 inserted");
|
|
assert_eq!(created.address.as_ref(), "10.0.3.7");
|
|
assert_eq!(created.port, 22);
|
|
});
|
|
}
|
|
|
|
/// What the user reported: the keychain has the working password, the edit
|
|
/// dialog's field has been changed to a wrong one, and 「测试连接」 must try the
|
|
/// field — without saving anything.
|
|
#[gpui_kit::test]
|
|
async fn testing_a_connection_logs_in_with_what_the_form_shows(cx: &mut TestAppContext) {
|
|
let secrets = Arc::new(InMemorySecretStore::default());
|
|
let (store, id, endpoint) = store_with_secrets(secrets.clone());
|
|
secrets.set(&endpoint, "hunter2").unwrap();
|
|
let tester = Arc::new(FakeConnectionTester::failing("用户名或密码错误"));
|
|
let (handle, workspace) = open_workspace_with_tester(cx, store, tester.clone());
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(EditHost(id)), cx);
|
|
})
|
|
.unwrap();
|
|
// The saved password is read on a background thread, then fills the field.
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("host-password", cx);
|
|
window.press("cmd-a", cx);
|
|
window.input("wrong-password", cx);
|
|
window.click("host-port", cx);
|
|
window.press("cmd-a", cx);
|
|
window.input("2222", cx);
|
|
window.click("test-connection", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).len() == 1
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(
|
|
tester.requests(),
|
|
[(
|
|
"10.0.2.5".to_string(),
|
|
2222,
|
|
"postgres".to_string(),
|
|
Some("wrong-password".to_string())
|
|
)]
|
|
);
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
// Nothing was saved, and the dialog stays open to fix the field.
|
|
assert!(window.find("commit").visible());
|
|
})
|
|
.unwrap();
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
assert_eq!(store.host(id).unwrap().port, 22);
|
|
});
|
|
assert_eq!(
|
|
secrets
|
|
.get(&endpoint)
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
Some("hunter2"),
|
|
"测试连接不写钥匙串"
|
|
);
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext) {
|
|
let tester = Arc::new(FakeConnectionTester::default());
|
|
let (handle, _) = open_workspace_with_tester(cx, HostStore::empty(), tester.clone());
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("new-host", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
let test = window.find("test-connection").bounds();
|
|
let cancel = window.find("cancel").bounds();
|
|
let commit = window.find("commit").bounds();
|
|
assert!(test.right() < cancel.left());
|
|
assert!(cancel.right() < commit.left());
|
|
assert!(window.notifications(cx).is_empty());
|
|
window.click("test-connection", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
// Reported at once as a failed test; the form shows no result of its own.
|
|
assert_eq!(window.notifications(cx).len(), 1);
|
|
assert!(window.try_find("form-error").is_none());
|
|
window.click("host-address", cx);
|
|
window.input("10.0.3.7", cx);
|
|
window.click("host-user", cx);
|
|
#[cfg(target_os = "macos")]
|
|
window.press("cmd-a", cx);
|
|
#[cfg(not(target_os = "macos"))]
|
|
window.press("ctrl-a", cx);
|
|
window.press("backspace", cx);
|
|
window.click("test-connection", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert_eq!(window.notifications(cx).len(), 2, "没有用户名也不该去连");
|
|
})
|
|
.unwrap();
|
|
assert!(tester.requests().is_empty());
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_first_seen_host_key_is_put_to_the_user_above_the_host_dialog(cx: &mut TestAppContext) {
|
|
let tester = Arc::new(FakeConnectionTester::asking_trust());
|
|
let (store, id) = one_host_store(AuthKind::Password);
|
|
let (handle, _) = open_workspace_with_tester(cx, store, tester.clone());
|
|
|
|
// Trust: the question reaches the tester as a yes.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(EditHost(id)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("test-connection", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("ok").is_some()
|
|
})
|
|
.await;
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("ok", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).len() == 1
|
|
})
|
|
.await;
|
|
assert_eq!(tester.trust_answers(), [true]);
|
|
|
|
// Escape dismisses the question, which declines it; the host dialog
|
|
// underneath stays open.
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.try_find("ok").is_none());
|
|
window.click("test-connection", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.try_find("ok").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(3), |window, cx| {
|
|
window.render_frame(cx);
|
|
window.notifications(cx).len() == 2
|
|
})
|
|
.await;
|
|
assert_eq!(tester.trust_answers(), [true, false]);
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.find("commit").visible());
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
/// A store holding one host, wired to a keychain the test can inspect.
|
|
fn store_with_secrets(secrets: Arc<InMemorySecretStore>) -> (HostStore, HostId, SecretRef) {
|
|
let mut store = HostStore::empty();
|
|
let id = store.insert_unnotified(HostDraft::new(
|
|
"db-01",
|
|
"10.0.2.5",
|
|
22,
|
|
"postgres",
|
|
AuthKind::Password,
|
|
None,
|
|
));
|
|
let endpoint = store.host(id).unwrap().password_secret();
|
|
(store.with_secrets(secrets), id, endpoint)
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_new_host_saves_its_password_to_the_keychain(cx: &mut TestAppContext) {
|
|
let secrets = Arc::new(InMemorySecretStore::default());
|
|
let (handle, workspace) =
|
|
open_workspace_with_store(cx, HostStore::empty().with_secrets(secrets.clone()));
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("new-host", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("host-name", cx);
|
|
window.input("db-02", cx);
|
|
window.click("host-address", cx);
|
|
window.input("10.0.3.7", cx);
|
|
window.click("host-password", cx);
|
|
window.input("hunter2", cx);
|
|
window.click("commit", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
|
window.try_find("commit").is_none()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
secrets
|
|
.get(&SecretRef::password("root", "10.0.3.7", 22))
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
Some("hunter2")
|
|
);
|
|
|
|
cx.update(|cx| {
|
|
let store = workspace.read(cx).store().read(cx);
|
|
let created = store
|
|
.hosts()
|
|
.iter()
|
|
.find(|host| host.name == "db-02")
|
|
.expect("db-02 inserted");
|
|
assert!(
|
|
!format!("{created:?}").contains("hunter2"),
|
|
"主机本身不该带着密码"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn editing_the_host_moves_the_saved_password_with_it(cx: &mut TestAppContext) {
|
|
let secrets = Arc::new(InMemorySecretStore::default());
|
|
let (store, id, endpoint) = store_with_secrets(secrets.clone());
|
|
secrets.set(&endpoint, "hunter2").unwrap();
|
|
let (handle, _) = open_workspace_with_store(cx, store);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(EditHost(id)), cx);
|
|
})
|
|
.unwrap();
|
|
// The saved password is read on a background thread, then fills the field.
|
|
cx.run_until_parked();
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
assert!(window.find("host-password").visible());
|
|
window.click("host-address", cx);
|
|
window.press("cmd-a", cx);
|
|
window.input("10.9.9.9", cx);
|
|
window.click("commit", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
|
window.try_find("commit").is_none()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
secrets
|
|
.get(&SecretRef::password("postgres", "10.9.9.9", 22))
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
Some("hunter2"),
|
|
"预填的密码应当跟着主机搬到新端点"
|
|
);
|
|
assert_eq!(
|
|
secrets
|
|
.get(&endpoint)
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
None,
|
|
"没有主机再用旧端点了,旧条目应当被清掉"
|
|
);
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn clearing_the_password_field_forgets_the_saved_password(cx: &mut TestAppContext) {
|
|
let secrets = Arc::new(InMemorySecretStore::default());
|
|
let (store, id, endpoint) = store_with_secrets(secrets.clone());
|
|
secrets.set(&endpoint, "hunter2").unwrap();
|
|
let (handle, _) = open_workspace_with_store(cx, store);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(EditHost(id)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("host-password", cx);
|
|
window.press("cmd-a", cx);
|
|
window.press("backspace", cx);
|
|
window.click("commit", cx);
|
|
})
|
|
.unwrap();
|
|
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
|
|
window.try_find("commit").is_none()
|
|
})
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
secrets
|
|
.get(&endpoint)
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
None,
|
|
"清空字段就是要删掉已保存的密码"
|
|
);
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn deleting_the_last_host_on_an_endpoint_forgets_its_password(cx: &mut TestAppContext) {
|
|
let secrets = Arc::new(InMemorySecretStore::default());
|
|
let (store, id, endpoint) = store_with_secrets(secrets.clone());
|
|
secrets.set(&endpoint, "hunter2").unwrap();
|
|
let (handle, _) = open_workspace_with_store(cx, store);
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.dispatch_action(Box::new(DeleteHost(id)), cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
cx.update_window(handle.into(), |_, window, cx| {
|
|
window.render_frame(cx);
|
|
window.click("ok", cx);
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
assert_eq!(
|
|
secrets
|
|
.get(&endpoint)
|
|
.unwrap()
|
|
.as_deref()
|
|
.map(String::as_str),
|
|
None,
|
|
"最后一个用这个端点的主机没了,密码也该没了"
|
|
);
|
|
}
|
|
|
|
#[gpui_kit::test]
|
|
async fn a_dialogs_choices_are_equal_segments_of_one_track(cx: &mut TestAppContext) {
|
|
let (handle, _) = open_workspace_with_credentials(
|
|
cx,
|
|
HostStore::seed(),
|
|
Arc::new(RecordingRemoteProvider::default()),
|
|
Arc::new(FakeConnectionTester::default()),
|
|
);
|
|
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
|
|
in_frame(cx, handle, |window, cx| {
|
|
let name = window.find("host-name").bounds();
|
|
let mut group = window.within("host-auth-source");
|
|
let segments = [0usize, 1, 2].map(|ix| group.find(ix));
|
|
let selected: Vec<_> = segments.iter().map(|segment| segment.selected()).collect();
|
|
assert_eq!(selected, [Some(true), Some(false), Some(false)]);
|
|
let [password, saved, keyless] = segments.map(|segment| segment.bounds());
|
|
// Side by side in one row, sharing the track equally (to the
|
|
// layout's rounding). The track spans the form like the name field
|
|
// above it, and the segments fill it but for its inset.
|
|
for segment in [saved, keyless] {
|
|
assert_eq!(segment.top(), password.top());
|
|
assert_eq!(segment.size.height, password.size.height);
|
|
assert!((segment.size.width - password.size.width).abs() <= px(1.));
|
|
}
|
|
assert!(password.right() <= saved.left() && saved.right() <= keyless.left());
|
|
assert!(name.left() < password.left() && keyless.right() < name.right());
|
|
assert!(password.size.width * 3. > name.size.width * 0.95);
|
|
group.click(1usize, cx);
|
|
});
|
|
in_frame(cx, handle, |window, _| {
|
|
let group = window.within("host-auth-source");
|
|
assert_eq!(group.find(1usize).selected(), Some(true));
|
|
assert_eq!(group.find(0usize).selected(), Some(false));
|
|
assert!(window.find("host-credential").visible());
|
|
});
|
|
}
|