Files
shell-rs/tests/ui/workspace.rs
T
Xuz 388a1aa68a UI 测试按功能拆成模块:tests/ui.rs → tests/ui/
- 仍是一个测试程序(tests/ui/main.rs,cargo test --test ui),每个文件单独成测试程序会再链接一遍 GPUI
- 按功能分 11 个模块:host_dialog、host_tree、connection、terminal、workspace、settings、
  sftp、transfer、forward、credential、update
- 两个以上模块共用的夹具和假实现放进 support/:mod.rs 是公共的 use、种子 id 和通用助手,
  其余按终端、SFTP、端口转发、测试连接、凭据分文件;只有一个模块用的留在那个模块里
- 纯搬移,测试逻辑不变:147 个测试名称与搬移前一致,全部通过
- CLAUDE.md 写明模块划分和新测试该放哪里,AGENTS.md、README 改路径
2026-10-01 14:53:33 +08:00

700 lines
24 KiB
Rust

//! The window around the features: tabs and closing them, the start page,
//! the title bar and dialogs in general.
use crate::support::*;
#[gpui_kit::test]
fn tab_close_button_closes_the_terminal_and_disconnects(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", DB_01), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
window.click(("close-terminal", FIRST_NEW_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
assert!(
window
.try_find(("close-terminal", FIRST_NEW_TERMINAL))
.is_none()
);
// The other seeded tabs are untouched.
assert!(
window
.find(("close-terminal", INITIAL_WEB_TERMINAL))
.visible()
);
})
.unwrap();
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.terminal(HostId(DB_01), cx).is_none());
let store = workspace.store().read(cx);
assert!(!store.host(HostId(DB_01)).unwrap().state.is_connected());
});
}
#[gpui_kit::test]
async fn closing_every_tab_shows_the_recent_hosts(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// While tabs are open the start page stays out of the way.
assert!(window.try_find("recent-hosts").is_none());
window.click(("close-terminal", INITIAL_WEB_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(
window
.try_find(("terminal", INITIAL_WEB_TERMINAL))
.is_none()
);
assert!(window.try_find("recent-hosts").is_none());
// The last tab closes too (the tab group alone would refuse).
window.click(("close-terminal", INITIAL_STAGING_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(
window
.try_find(("terminal", INITIAL_STAGING_TERMINAL))
.is_none()
);
assert!(window.find("recent-hosts").visible());
// The page takes the focus the closed tab held.
assert_eq!(window.find("recent-hosts").focused(), Some(true));
// Both hosts had been connected, so both are listed.
assert!(window.find(("recent-host", WEB_01)).visible());
assert!(window.find(("recent-host", STAGING_API)).visible());
assert_eq!(window.find("status-connection").label(), Some("未连接"));
window.click(("recent-host", WEB_01), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-hosts").visible());
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
window.double_click(("recent-host", WEB_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("已连接 web-01")
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
assert!(window.try_find("recent-hosts").is_none());
assert_eq!(
window.find("status-connection").label(),
Some("已连接 web-01")
);
})
.unwrap();
cx.update(|cx| {
let store = workspace.read(cx).store().read(cx);
let recent: Vec<_> = store
.recent_hosts()
.map(|host| host.name.to_string())
.collect();
// Reconnecting moved web-01 to the front.
assert_eq!(recent, ["web-01", "staging-api"]);
});
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("close-terminal", FIRST_NEW_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-hosts").visible());
// Reopening the page must not reconnect the old selection on Enter.
window.press("enter", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-hosts").visible());
assert!(
window
.try_find(("terminal", FIRST_NEW_TERMINAL + 1))
.is_none()
);
})
.unwrap();
}
#[gpui_kit::test]
async fn enter_connects_the_selected_recent_host(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("close-terminal", INITIAL_WEB_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("close-terminal", INITIAL_STAGING_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("recent-host", STAGING_API), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-hosts").visible());
window.press("enter", 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("已连接 staging-api")
})
.await;
}
/// The start page's row menu is the host tree's menu. Menus are not
/// driven here, so this dispatches what its items dispatch, from the page:
/// the page is drawn deferred over the dock, and its actions must still
/// reach the workspace.
#[gpui_kit::test]
async fn recent_host_menu_commands_work_from_the_start_page(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
for terminal in [INITIAL_WEB_TERMINAL, INITIAL_STAGING_TERMINAL] {
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_eq!(window.find("recent-hosts").focused(), Some(true));
window.dispatch_action(Box::new(EditHost(HostId(STAGING_API))), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find("host-name").value(), Some("staging-api"));
window.press("escape", cx);
})
.unwrap();
cx.run_until_parked();
// A closed dialog leaves nothing focused; clicking the row focuses the
// page again, as a right click would.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("recent-host", WEB_01), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(Box::new(DeleteHost(HostId(WEB_01))), 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();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find(("recent-host", WEB_01)).is_none());
assert!(window.find(("recent-host", STAGING_API)).visible());
})
.unwrap();
}
#[gpui_kit::test]
fn close_shortcut_closes_the_displayed_tab_down_to_none(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", DB_01), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// The new tab is displayed, with its input focused.
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
window.press("cmd-w", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find(("terminal", FIRST_NEW_TERMINAL)).is_none());
assert!(window.try_find("recent-hosts").is_none());
// Keep closing whichever tab the dock displays next.
window.press("cmd-w", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.press("cmd-w", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(
window
.try_find(("terminal", INITIAL_WEB_TERMINAL))
.is_none()
);
assert!(
window
.try_find(("terminal", INITIAL_STAGING_TERMINAL))
.is_none()
);
assert!(window.find("recent-hosts").visible());
// With nothing open the shortcut does nothing.
window.press("cmd-w", cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-hosts").visible());
})
.unwrap();
}
/// A host's terminal and SFTP tabs sit side by side in the center, and
/// only the active one renders. The tab going inactive used to keep the
/// window focus, which took its focus handle out of the dispatch tree and
/// left every 「×」 dead.
#[gpui_kit::test]
fn both_tabs_of_one_host_stay_closable(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.within("host-tree")
.double_click(("host-row", DB_01), cx);
})
.unwrap();
cx.run_until_parked();
// The terminal tab is active and focused; opening SFTP puts a second tab
// for the same host beside it and activates that one.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("sftp", FIRST_NEW_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find(("explorer", SFTP_TAB)).visible());
window.click(("close-explorer", SFTP_TAB), cx);
})
.unwrap();
cx.run_until_parked();
// The SFTP tab is gone and the terminal tab, now active again, still closes.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find(("close-explorer", SFTP_TAB)).is_none());
assert!(window.find(("terminal", FIRST_NEW_TERMINAL)).visible());
window.click(("close-terminal", FIRST_NEW_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(
window
.try_find(("close-terminal", FIRST_NEW_TERMINAL))
.is_none()
);
})
.unwrap();
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.explorer(ExplorerId(SFTP_TAB)).is_none());
assert!(workspace.terminal(HostId(DB_01), cx).is_none());
});
}
/// Double-clicking a tab's title shows or hides the host sidebar; a
/// single click only selects the tab.
#[gpui_kit::test]
async fn double_clicking_a_tab_toggles_the_host_sidebar(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
cx.run_until_parked();
let sidebar_shown = |cx: &mut TestAppContext| {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window
.try_find("host-search")
.is_some_and(|search| search.visible())
})
.unwrap()
};
assert!(sidebar_shown(cx));
for shown in [false, true] {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.double_click(("terminal-tab", INITIAL_WEB_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
assert_eq!(sidebar_shown(cx), shown);
}
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click(("terminal-tab", INITIAL_STAGING_TERMINAL), cx);
})
.unwrap();
cx.run_until_parked();
assert!(sidebar_shown(cx));
}
#[gpui_kit::test]
async fn a_terminal_tab_can_be_renamed_and_follow_the_host_again(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let tab = ("terminal-tab", INITIAL_WEB_TERMINAL);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(tab).label(), Some("web-01"));
// Undetected hosts show the fallback mark in the tab too.
assert_eq!(
window
.find(("terminal-tab-os", INITIAL_WEB_TERMINAL))
.label(),
Some("未探测到系统")
);
window.dispatch_action(
Box::new(RenameTerminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))),
cx,
);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find("tab-name").value(), Some("web-01"));
window.click("tab-name", cx);
window.press("cmd-a", cx);
window.input("日志排查", cx);
window.click("commit", cx);
})
.unwrap();
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(tab).label(), Some("日志排查"));
// The other tab of the bar keeps its host name.
assert_eq!(
window
.find(("terminal-tab", INITIAL_STAGING_TERMINAL))
.label(),
Some("staging-api")
);
})
.unwrap();
cx.update(|cx| {
// A tab title is not a host setting.
let store = workspace.read(cx).store().read(cx);
let host = store.host(HostId(WEB_01)).expect("host kept");
assert_eq!(host.name.as_ref(), "web-01");
});
// Clearing the field returns the tab to the host name.
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
// A closed dialog leaves nothing focused, and an action only reaches
// handlers on the focused element's path.
window.click("host-search", cx);
window.dispatch_action(
Box::new(RenameTerminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))),
cx,
);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find("tab-name").value(), Some("日志排查"));
window.click("tab-name", 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.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.find(tab).label(), Some("web-01"));
})
.unwrap();
}
#[gpui_kit::test]
fn batch_close_commands_take_the_tabs_around_the_clicked_one(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let dispatch = |cx: &mut TestAppContext, action: Box<dyn gpui_kit::Action>| {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.dispatch_action(action, cx);
})
.unwrap();
cx.run_until_parked();
};
let close = |tab, scope| Box::new(CloseTabs { tab, scope });
let terminal = |id| CenterTab::Terminal(RemoteTerminalId(id));
let open_terminals = |cx: &mut TestAppContext| {
cx.update(|cx| {
let workspace = workspace.read(cx);
(1..=FIRST_NEW_TERMINAL + 1)
.filter(|id| workspace.remote_terminal(RemoteTerminalId(*id)).is_some())
.collect::<Vec<_>>()
})
};
// [web-01, staging-api, web-01 · SFTP, local]
dispatch(cx, Box::new(OpenExplorer(HostId(WEB_01))));
dispatch(cx, Box::new(NewLocalTerminal));
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.explorer(ExplorerId(SFTP_TAB)).is_some());
assert!(workspace.local_terminal(LocalTerminalId(1)).is_some());
});
// Right of staging-api: the SFTP and local tabs, whatever their kind.
dispatch(
cx,
close(terminal(INITIAL_STAGING_TERMINAL), CloseScope::Right),
);
cx.update(|cx| {
let workspace = workspace.read(cx);
assert!(workspace.explorer(ExplorerId(SFTP_TAB)).is_none());
assert!(workspace.local_terminal(LocalTerminalId(1)).is_none());
});
assert_eq!(
open_terminals(cx),
[INITIAL_WEB_TERMINAL, INITIAL_STAGING_TERMINAL]
);
// [web-01, staging-api, web-01 #2] → left of staging-api.
dispatch(cx, Box::new(ConnectHost(HostId(WEB_01))));
dispatch(
cx,
close(terminal(INITIAL_STAGING_TERMINAL), CloseScope::Left),
);
assert_eq!(
open_terminals(cx),
[INITIAL_STAGING_TERMINAL, FIRST_NEW_TERMINAL]
);
// [staging-api, web-01 #2, staging-api #2] → others than web-01 #2.
dispatch(cx, Box::new(ConnectHost(HostId(STAGING_API))));
assert_eq!(open_terminals(cx).len(), 3);
dispatch(cx, close(terminal(FIRST_NEW_TERMINAL), CloseScope::Others));
assert_eq!(open_terminals(cx), [FIRST_NEW_TERMINAL]);
// All of them, down to the start page.
dispatch(cx, close(terminal(FIRST_NEW_TERMINAL), CloseScope::All));
assert!(open_terminals(cx).is_empty());
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-hosts").visible());
})
.unwrap();
}
#[gpui_kit::test]
async fn the_start_page_marks_recent_hosts_with_their_operating_system(cx: &mut TestAppContext) {
let mut store = HostStore::empty();
let id = store.insert_unnotified(HostDraft::new(
"web-01",
"10.0.1.12",
22,
"root",
AuthKind::Password,
None,
));
// Connecting once puts it on the start page; disconnecting keeps it there
// and leaves the centre empty, which is when that page shows.
store.set_state_unnotified(id, ConnectionState::Connected);
store.set_state_unnotified(id, ConnectionState::Disconnected);
store.set_host_os_unnotified(id, Some(HostOs::Ubuntu));
let (handle, _) = open_workspace_with_store(cx, store);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.find("recent-hosts").visible());
assert_eq!(
window.find(("recent-host-os", id.0)).label(),
Some("Ubuntu"),
"开始页和主机树用同一个标记"
);
})
.unwrap();
}
/// A dialog holds what was typed into it: a click beside it does not close
/// it, Escape (like its buttons) does.
#[gpui_kit::test]
async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) {
let (handle, _) = open_workspace_with_forwards(
cx,
HostStore::seed(),
Arc::new(FakeForwardProvider::default()),
);
cx.run_until_parked();
for (open, field) in [
("new-host", "host-name"),
("new-group", "group-name"),
("new-forward", "forward-name"),
("new-credential", "credential-name"),
] {
if open == "new-forward" {
show_forwards(cx, handle).await;
}
if open == "new-credential" {
show_credentials(cx, handle).await;
}
in_frame(cx, handle, |window, cx| window.click(open, cx));
in_frame(cx, handle, |window, cx| {
window.click(field, cx);
window.input("typed", cx);
});
// The status bar is outside the dialog, under its backdrop.
in_frame(cx, handle, |window, cx| {
window.click("status-connection", cx)
});
in_frame(cx, handle, |window, cx| {
assert!(
window.try_find("commit").is_some(),
"{open}: a click beside the dialog closed it"
);
assert_eq!(window.find(field).value(), Some("typed"));
window.press("escape", cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| {
window.try_find("commit").is_none()
})
.await;
}
}
/// What goes wrong while the window is being built (a database that will
/// not open, a service that will not start) is said in a notification, and
/// the window has no `Root` to show one yet. Pushing it there and then
/// crashed the app at launch.
#[gpui_kit::test]
fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut TestAppContext) {
cx.update(shellrs::init);
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let store = cx.new(|_| HostStore::empty());
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
)));
let view = cx.new(|cx| {
Workspace::new_with_services(
store,
cx.new(|_| SettingsStore::in_memory()),
remote,
Arc::new(FakeTerminalFactory::default()),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
Arc::new(FakeForwardProvider::default()),
window,
cx,
)
});
// As `main` does for a database it could not open.
shellrs::workspace::notify_once_open(
Notification::error("无法打开本地数据库,本次运行的改动不会被保存"),
window,
cx,
);
Root::new(view, window, cx)
});
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.notifications(cx).len(), 1);
assert!(window.find("host-search").visible());
})
.unwrap();
}