Files
shell-rs/tests/ui/cli.rs
T
XuzandClaude Opus 5.5 e09adeefb7 外部 CLI 新增 exec --terminal:在已打开的终端里执行命令
只允许登录一次的堡垒机(外部连接)上,exec 重新登录会被拒绝,另开通道又会结束会话。
--terminal 把命令打进这台主机在 ShellRS 里已打开的远程终端:先发 Ctrl-C,探测出
交互 shell(bash、dash、busybox ash)后输入命令行,经 OSC 标记取回输出、退出码和
执行上下文(--json 的 context)。不做忙碌检测,只拒绝全屏程序和同一终端已有一次运行。
外部连接上普通 exec 登录失败时,说明里提示改用 --terminal。

shells.yml 在 Docker 里对 bash 4.2–5.2、dash、busybox ash 跑真实终端测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:16:30 +08:00

538 lines
17 KiB
Rust

//! The changes the external CLI asks for, as the workspace makes them:
//! hosts and credentials created, changed and deleted from `shellrs`.
//! The socket is not in it; `apply_cli_change` is where the server hands
//! each change over.
use shellrs::cli::{CliChange, CliError, CredentialFields, ErrorCode, HostFields, Reply, Secret};
use shellrs::terminal::{
BusyReason, RunEvent, RunFailure, RunProgress, RunTiming, TerminalRun, TerminalView,
};
use crate::support::*;
/// Make `change` as the server's poller would, and wait for its outcome.
fn apply(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
workspace: &Entity<Workspace>,
change: CliChange,
) -> Result<Reply, CliError> {
let outcome = in_frame(cx, handle, |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.apply_cli_change(change, window, cx)
})
});
let outcome = cx.foreground_executor.block_test(outcome);
cx.run_until_parked();
outcome
}
fn host_id(workspace: &Entity<Workspace>, id: u64, cx: &mut TestAppContext) -> String {
cx.update(|cx| {
workspace
.read(cx)
.store()
.read(cx)
.host(HostId(id))
.unwrap()
.public_id
.to_string()
})
}
fn store(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Entity<HostStore> {
cx.update(|cx| workspace.read(cx).store().clone())
}
#[gpui_kit::test]
fn a_host_from_the_cli_lands_in_the_tree_with_its_group_and_password(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let (handle, workspace) =
open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone()));
let fields = HostFields {
name: Some("api-01".into()),
host: Some("10.0.9.1".into()),
group: Some(Some("生产/接口".into())),
password: Some(Some(Secret::new("s3cret"))),
..HostFields::default()
};
let Ok(Reply::Host(created)) = apply(cx, handle, &workspace, CliChange::CreateHost(fields))
else {
panic!("not created");
};
assert_eq!(created.group.as_deref(), Some("生产/接口"));
assert_eq!(created.password_saved, Some(true));
let store = store(&workspace, cx);
let host = cx.update(|cx| {
let store = store.read(cx);
let host = store
.hosts()
.iter()
.find(|host| host.public_id.as_str() == created.id)
.unwrap();
let group = store.group(host.group.unwrap()).unwrap();
assert_eq!(group.name.as_ref(), "接口");
assert_eq!(group.parent, Some(GroupId(PRODUCTION)));
host.id
});
assert_eq!(
secrets
.get(&SecretRef::password("root", "10.0.9.1", 22))
.unwrap()
.as_deref()
.map(String::as_str),
Some("s3cret")
);
in_frame(cx, handle, |window, _| {
assert!(
window
.within("host-tree")
.try_find(("host-row", host.0))
.is_some()
);
});
// Checked as the host form checks it.
let nameless = HostFields {
host: Some("10.0.9.2".into()),
..HostFields::default()
};
let error = apply(cx, handle, &workspace, CliChange::CreateHost(nameless)).unwrap_err();
assert_eq!(
(error.code, error.message.as_str()),
(ErrorCode::BadRequest, "请输入名称")
);
}
#[gpui_kit::test]
fn a_host_moved_to_another_port_keeps_its_password_until_told_otherwise(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let before = SecretRef::password("postgres", "10.0.2.5", 22);
secrets.set(&before, "pw").unwrap();
let (handle, workspace) =
open_workspace_with_store(cx, HostStore::seed().with_secrets(secrets.clone()));
let db = host_id(&workspace, DB_01, cx);
let moved = HostFields {
port: Some(2222),
..HostFields::default()
};
let Ok(Reply::Host(changed)) = apply(
cx,
handle,
&workspace,
CliChange::UpdateHost {
host: db.clone(),
fields: moved,
},
) else {
panic!("not changed");
};
assert_eq!((changed.port, changed.password_saved), (2222, Some(true)));
let after = SecretRef::password("postgres", "10.0.2.5", 2222);
assert_eq!(
secrets.get(&after).unwrap().as_deref().map(String::as_str),
Some("pw")
);
// The entry it left goes, as no other host uses it.
assert!(secrets.get(&before).unwrap().is_none());
let forget = HostFields {
password: Some(None),
..HostFields::default()
};
let Ok(Reply::Host(changed)) = apply(
cx,
handle,
&workspace,
CliChange::UpdateHost {
host: db,
fields: forget,
},
) else {
panic!("not changed");
};
assert_eq!(changed.password_saved, Some(false));
assert!(secrets.get(&after).unwrap().is_none());
}
#[gpui_kit::test]
fn a_host_with_tabs_open_goes_only_with_force_and_takes_them_along(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let web = host_id(&workspace, WEB_01, cx);
let delete = |force| CliChange::DeleteHost {
host: web.clone(),
force,
};
let error = apply(cx, handle, &workspace, delete(false)).unwrap_err();
assert_eq!(error.code, ErrorCode::HostInUse);
let store = store(&workspace, cx);
assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_some()));
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find(("terminal-tab", INITIAL_WEB_TERMINAL))
.is_some()
);
});
let Ok(Reply::HostDeleted(deleted)) = apply(cx, handle, &workspace, delete(true)) else {
panic!("not deleted");
};
assert_eq!(deleted.host.name, "web-01");
assert!(cx.update(|cx| store.read(cx).host(HostId(WEB_01)).is_none()));
in_frame(cx, handle, |window, _| {
assert!(
window
.try_find(("terminal-tab", INITIAL_WEB_TERMINAL))
.is_none()
);
});
// Nothing open, nothing to force.
let db = host_id(&workspace, DB_01, cx);
let deleted = apply(
cx,
handle,
&workspace,
CliChange::DeleteHost {
host: db,
force: false,
},
);
assert!(matches!(deleted, Ok(Reply::HostDeleted(_))));
}
#[gpui_kit::test]
fn a_host_not_saved_is_left_to_its_tabs(cx: &mut TestAppContext) {
let mut seeded = HostStore::seed();
let temporary = seeded.insert_temporary_unnotified(
HostDraft::new("临时", "10.0.0.3", 22, "root", AuthKind::Password, None),
None,
);
let (handle, workspace) = open_workspace_with_store(cx, seeded);
let id = cx.update(|cx| {
workspace
.read(cx)
.store()
.read(cx)
.host(temporary)
.unwrap()
.public_id
.to_string()
});
for change in [
CliChange::UpdateHost {
host: id.clone(),
fields: HostFields::default(),
},
CliChange::DeleteHost {
host: id.clone(),
force: true,
},
] {
let error = apply(cx, handle, &workspace, change).unwrap_err();
assert_eq!(error.code, ErrorCode::BadRequest);
assert!(
error.message.contains("没有保存的连接"),
"{}",
error.message
);
}
}
#[gpui_kit::test]
fn a_pasted_key_becomes_a_credential_shellrs_keeps(cx: &mut TestAppContext) {
let secrets = Arc::new(InMemorySecretStore::default());
let data = tempfile::tempdir().unwrap();
let (handle, workspace) = open_workspace_with_store(
cx,
HostStore::empty()
.with_secrets(secrets.clone())
.with_key_dir(data.path().join("keys")),
);
let key = GeneratedKey::generate(KeyAlgorithm::Ed25519).unwrap();
let text = key.encode("deploy", "correct horse").unwrap().to_string();
let fields = CredentialFields {
name: Some("部署".into()),
user: Some("deploy".into()),
private_key: Some(Secret::new(text.clone())),
passphrase: Some(Some(Secret::new("correct horse"))),
..CredentialFields::default()
};
let Ok(Reply::Credential(created)) =
apply(cx, handle, &workspace, CliChange::CreateCredential(fields))
else {
panic!("not created");
};
assert!(created.kept);
// Where it is kept is not said: the file is as good as the key.
assert_eq!(created.key_path, None);
assert_eq!(created.passphrase_saved, Some(true));
let store = store(&workspace, cx);
let path = cx.update(|cx| {
let store = store.read(cx);
let credential = &store.credentials()[0];
assert_eq!(credential.keychain_id.as_str(), created.id);
assert_eq!(credential.kind, CredentialKind::Key);
credential.key_path.clone().unwrap()
});
assert!(path.starts_with(data.path().join("keys").to_str().unwrap()));
assert_eq!(std::fs::read_to_string(path.as_ref()).unwrap(), text);
assert_eq!(
secrets
.get(&SecretRef::passphrase(path.as_ref()))
.unwrap()
.as_deref()
.map(String::as_str),
Some("correct horse")
);
}
#[gpui_kit::test]
fn deleting_a_credential_lets_its_hosts_log_in_on_their_own(cx: &mut TestAppContext) {
let mut seeded = HostStore::empty();
let credential = seeded.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
"deploy",
));
let host = seeded.insert_unnotified(
HostDraft::new("db-01", "10.0.2.5", 22, "root", AuthKind::Password, None)
.with_credential(credential),
);
let id = seeded
.credential(credential)
.unwrap()
.keychain_id
.to_string();
let (handle, workspace) = open_workspace_with_store(cx, seeded);
let Ok(Reply::CredentialDeleted(deleted)) = apply(
cx,
handle,
&workspace,
CliChange::DeleteCredential { credential: id },
) else {
panic!("not deleted");
};
assert_eq!(deleted.credential.name, "运维");
let released: Vec<&str> = deleted
.released
.iter()
.map(|host| host.name.as_str())
.collect();
assert_eq!(released, ["db-01"]);
let store = store(&workspace, cx);
cx.update(|cx| {
let store = store.read(cx);
assert!(store.credentials().is_empty());
let host = store.host(host).unwrap();
assert_eq!((host.credential, host.auth), (None, AuthKind::Password));
assert_eq!(host.user.as_ref(), "deploy");
});
}
/// How an `exec --terminal` run is paced against a fake shell that
/// answers at once.
const QUICK: RunTiming = RunTiming {
settle: Duration::ZERO,
probe: Duration::from_secs(2),
start: Duration::from_secs(2),
tick: Duration::from_millis(10),
};
/// The seed with web-01's terminal running on a transport that behaves as
/// `behavior`.
async fn open_terminal(
cx: &mut TestAppContext,
behavior: FakeBehavior,
) -> (
WindowHandle<Root>,
Entity<Workspace>,
Arc<FakeTerminalFactory>,
) {
let factory = Arc::new(FakeTerminalFactory {
behavior,
..FakeTerminalFactory::default()
});
let (handle, workspace) =
open_workspace_with_remote_factory(cx, HostStore::seed(), factory.clone());
let terminal = RemoteTerminalId(INITIAL_WEB_TERMINAL);
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
remote_lifecycle(&workspace, terminal, cx).accepts_input()
})
.await;
(handle, workspace, factory)
}
fn start_run(
workspace: &Entity<Workspace>,
host: &str,
run: &TerminalRun,
cx: &mut TestAppContext,
) -> Result<(), RunFailure> {
cx.update(|cx| workspace.read(cx).start_cli_run(host, run.handle(), cx))
}
fn web_terminal(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Entity<TerminalView> {
cx.update(|cx| {
workspace
.read(cx)
.remote_terminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))
.unwrap()
.read(cx)
.terminal()
.clone()
})
}
#[gpui_kit::test]
async fn a_cli_run_is_typed_into_the_hosts_terminal_after_a_ctrl_c(cx: &mut TestAppContext) {
let (_, workspace, factory) = open_terminal(
cx,
FakeBehavior::Shell {
exe: "/usr/bin/bash",
ends: true,
},
)
.await;
let web = host_id(&workspace, 1, cx);
let before = factory.written_text().len();
let (run, events) = TerminalRun::new("uname -a".into());
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
let (mut output, mut context) = (Vec::new(), None);
let code = run.drive(&events, QUICK, &mut |step| {
match step {
RunProgress::Context(found) => context = Some(found),
RunProgress::Output(bytes) => output.extend(bytes),
RunProgress::Idle => {}
}
Ok(())
});
assert_eq!(code, Ok(0));
assert_eq!(output, b"ran\n");
let context = context.expect("begun");
assert_eq!(
(
context.user.as_str(),
context.cwd.as_str(),
context.shell.as_str()
),
("root", "/root", "bash")
);
// ^C, the probe, the command line; never Ctrl-E, a byte to dash.
let typed = factory.written_text()[before..].to_string();
assert!(typed.starts_with("\x03 sh -c '"), "{typed:?}");
assert!(typed.ends_with(" 'uname -a'\r"), "{typed:?}");
assert_eq!(typed.matches(" sh -c '").count(), 2);
assert!(!typed.contains('\x05'));
}
#[gpui_kit::test]
async fn a_cli_run_a_terminal_cannot_take_says_why(cx: &mut TestAppContext) {
let (_, workspace, factory) = open_terminal(
cx,
FakeBehavior::Shell {
exe: "/usr/bin/zsh",
ends: true,
},
)
.await;
let (other, _) = TerminalRun::new("true".into());
assert_eq!(
start_run(&workspace, "nope", &other, cx),
Err(RunFailure::HostNotFound)
);
let db = host_id(&workspace, 3, cx);
assert_eq!(
start_run(&workspace, &db, &other, cx),
Err(RunFailure::NoTerminal)
);
// Another run has the terminal: no ^C to end it.
let web = host_id(&workspace, 1, cx);
let (first, first_events) = TerminalRun::new("sleep 9".into());
assert_eq!(start_run(&workspace, &web, &first, cx), Ok(()));
let before = factory.written_text().len();
let (second, _) = TerminalRun::new("true".into());
assert_eq!(
start_run(&workspace, &web, &second, cx),
Err(RunFailure::Busy(BusyReason::AnotherRun))
);
assert_eq!(factory.written_text().len(), before);
// A shell it cannot type into: only the probe was typed.
assert_eq!(first_events.recv().unwrap(), RunEvent::Accepted);
assert_eq!(
first.drive(&first_events, QUICK, &mut |_| Ok(())),
Err(RunFailure::UnsupportedShell("zsh".into()))
);
assert_eq!(
factory.written_text()[before..].matches(" sh -c '").count(),
1
);
// The terminal disconnected.
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
cx.run_until_parked();
let (third, _) = TerminalRun::new("true".into());
assert_eq!(
start_run(&workspace, &web, &third, cx),
Err(RunFailure::NotConnected)
);
}
#[gpui_kit::test]
async fn a_cli_run_leaves_a_full_screen_program_alone(cx: &mut TestAppContext) {
let (handle, workspace, factory) = open_terminal(cx, FakeBehavior::Prints("\x1b[?1049h")).await;
let terminal = web_terminal(&workspace, cx);
// The alternate screen is up: the prompt line is off it.
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
!terminal.read(cx).screen_text(cx).contains("alpha.txt")
})
.await;
let before = factory.written_text().len();
let (run, _) = TerminalRun::new("true".into());
let web = host_id(&workspace, 1, cx);
assert_eq!(
start_run(&workspace, &web, &run, cx),
Err(RunFailure::Busy(BusyReason::FullScreen))
);
assert_eq!(factory.written_text().len(), before);
}
#[gpui_kit::test]
async fn a_cli_run_whose_terminal_goes_away_may_have_run(cx: &mut TestAppContext) {
let (_, workspace, factory) = open_terminal(
cx,
FakeBehavior::Shell {
exe: "/usr/bin/bash",
ends: false,
},
)
.await;
let web = host_id(&workspace, 1, cx);
let (run, events) = TerminalRun::new("sleep 100".into());
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
let (begun, begun_here) = mpsc::channel();
let driver = std::thread::spawn(move || {
run.drive(&events, QUICK, &mut |step| {
if matches!(step, RunProgress::Context(_)) {
let _ = begun.send(());
}
Ok(())
})
});
begun_here
.recv_timeout(Duration::from_secs(5))
.expect("the command started");
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
cx.run_until_parked();
assert_eq!(driver.join().unwrap(), Err(RunFailure::Disconnected));
assert!(factory.written_text().contains(" 'sleep 100'\r"));
}