Files
shell-rs/src/cli/tests.rs
T
XuzandClaude Opus 5.5 d5370f8bac 外部连接:改名并在打开时收起左侧栏
命名(用户定的):命令行链接打开的叫「外部连接」(不保存、只开终端);以后在
ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。代码里共同的「不
保存」统一叫 temporary(HostStore 的 temporary / is_temporary、
SecretRef::Temporary、TemporarySecretStore,和 CLI 的 JSON 字段一致),「只开
终端」改由来源决定:HostStore::is_external,login_of 只对它置 shell_only,右
侧栏也看它。shellrs list 表格写「(外部连接)」。

外部连接打开时左侧栏开着就收起(同 ⌘B),终端占满窗口;链接有误时不收。
命令片段面板本来就列出全部片段,外部连接照常都能看到;以后做适用范围时外部
和临时连接只显示「所有主机」的,规则记在 CLAUDE.md。

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

605 lines
19 KiB
Rust

//! The command and the app's server talking over a real socket, with a
//! fake backend behind the server.
use std::{
io,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use clap::Parser as _;
use super::client::{self, Console};
use super::link::OpenLink;
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliServer, CliTarget};
use super::{Cli, Command};
use super::{ConsoleText, normalize_command};
use crate::app::cli_endpoint;
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink};
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
/// Records what it was asked and answers from a script.
#[derive(Default)]
struct FakeBackend {
calls: Mutex<Vec<String>>,
}
impl CliBackend for FakeBackend {
fn exec(
&self,
target: &CliTarget,
command: &str,
output: &mut dyn FnMut(ExecStream, &[u8]) -> io::Result<()>,
) -> Result<i32, CliError> {
self.calls
.lock()
.unwrap()
.push(format!("exec {} {command}", target.host().name));
if command == "unreachable" {
return Err(CliError::new(ErrorCode::HostKeyUnknown, "尚未信任"));
}
output(ExecStream::Stdout, b"out\n").unwrap();
output(ExecStream::Stderr, &[0xff, b'\n']).unwrap();
Ok(3)
}
fn upload(
&self,
target: &CliTarget,
source: &Path,
destination: &str,
progress: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
self.calls.lock().unwrap().push(format!(
"upload {} {} {destination}",
target.host().name,
source.display()
));
progress(TransferCounters {
files: 1,
total_files: 2,
bytes: 10,
total_bytes: 20,
})
.unwrap();
Ok(TransferSummary {
files: 1,
bytes: 20,
skipped: 0,
failed: 1,
failures: vec!["无法上传:permission denied".into()],
})
}
fn download(
&self,
_: &CliTarget,
_: &str,
_: &Path,
_: &mut dyn FnMut(TransferCounters) -> io::Result<()>,
) -> Result<TransferSummary, CliError> {
Ok(TransferSummary::default())
}
}
fn host(id: u64, name: &str, host: &str) -> Host {
Host::new(
HostId(id),
HostDraft::new(name, host, 22, "root", AuthKind::Password, Some(GroupId(1))),
)
}
struct Fixture {
_dir: tempfile::TempDir,
socket: PathBuf,
server: CliServer,
backend: Arc<FakeBackend>,
web: Host,
}
fn fixture() -> Fixture {
let dir = tempfile::tempdir().unwrap();
let socket = cli_endpoint(dir.path());
let backend = Arc::new(FakeBackend::default());
let server = CliServer::start(socket.clone(), backend.clone()).unwrap();
let web = host(1, "web-01", "10.0.1.12");
let db = host(2, "db-01", "10.0.2.5");
server.set_targets(vec![
CliTarget::new(&web, HostLogin::of(&web, None), Some("生产".into())),
CliTarget::new(&db, HostLogin::of(&db, None), Some("生产/数据库".into())),
]);
server.set_enabled(true);
Fixture {
_dir: dir,
socket,
server,
backend,
web,
}
}
/// Run one request the way the command does; its exit code, stdout and
/// stderr.
fn run(socket: &Path, request: Request, json: bool) -> (i32, String, Vec<u8>) {
run_on(socket, request, json, false)
}
fn run_on(
socket: &Path,
request: Request,
json: bool,
stderr_is_terminal: bool,
) -> (i32, String, Vec<u8>) {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = client::run(
socket,
request,
&mut Console {
stdout: &mut stdout,
stderr: &mut stderr,
json,
stderr_is_terminal,
},
);
(code, String::from_utf8(stdout).unwrap(), stderr)
}
/// A local path the app accepts: absolute on this system.
fn local(name: &str) -> PathBuf {
std::env::temp_dir().join(name)
}
fn id(host: &Host) -> String {
host.public_id.to_string()
}
#[test]
fn listing_filters_by_name_host_group_or_id() {
let fixture = fixture();
let list = |query: Option<&str>| {
let (code, stdout, _) = run(
&fixture.socket,
Request::List {
query: query.map(String::from),
},
true,
);
assert_eq!(code, 0);
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
hosts.into_iter().map(|host| host.name).collect::<Vec<_>>()
};
assert_eq!(list(None), ["web-01", "db-01"]);
assert_eq!(list(Some("WEB")), ["web-01"]);
assert_eq!(list(Some("10.0.2")), ["db-01"]);
assert_eq!(list(Some("数据库")), ["db-01"]);
assert_eq!(list(Some(&id(&fixture.web))), ["web-01"]);
assert!(list(Some("nothing")).is_empty());
let (_, stdout, _) = run(&fixture.socket, Request::List { query: None }, true);
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
assert_eq!(hosts[0].id, id(&fixture.web));
assert_eq!(hosts[0].group.as_deref(), Some("生产"));
assert_eq!(hosts[0].address(), "root@10.0.1.12:22");
}
#[test]
fn a_terminal_gets_a_table_lined_up_for_chinese() {
let fixture = fixture();
let (code, stdout, _) = run(&fixture.socket, Request::List { query: None }, false);
assert_eq!(code, 0);
let lines: Vec<&str> = stdout.lines().collect();
assert!(lines[0].starts_with("ID"));
assert!(lines[1].starts_with(&id(&fixture.web)));
// 分组 starts in the same column on every line, counting CJK as two.
let column = |line: &str, text: &str| {
use unicode_width::UnicodeWidthStr as _;
line[..line.find(text).unwrap()].width()
};
assert_eq!(column(lines[1], "生产"), column(lines[0], "分组"));
assert_eq!(column(lines[2], "生产/数据库"), column(lines[0], "分组"));
}
#[test]
fn exec_passes_output_through_as_bytes_and_exits_with_the_remote_code() {
let fixture = fixture();
let (code, stdout, stderr) = run(
&fixture.socket,
Request::Exec {
host: id(&fixture.web),
command: "uname -a".into(),
},
false,
);
assert_eq!(code, 3);
assert_eq!(stdout, "out\n");
assert_eq!(stderr, [0xff, b'\n']);
assert_eq!(
fixture.backend.calls.lock().unwrap().as_slice(),
["exec web-01 uname -a"]
);
}
#[test]
fn failures_carry_their_code_and_exit_255() {
let fixture = fixture();
let (code, _, stderr) = run(
&fixture.socket,
Request::Exec {
host: id(&fixture.web),
command: "unreachable".into(),
},
false,
);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [host_key_unknown]")
);
let (code, _, stderr) = run(
&fixture.socket,
Request::Exec {
host: "nobody".into(),
command: "true".into(),
},
false,
);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.contains("[host_not_found]")
);
// A path relative to the command is meaningless to the app.
let (code, _, stderr) = run(
&fixture.socket,
Request::Upload {
host: id(&fixture.web),
source: "relative/file".into(),
destination: "/tmp".into(),
},
false,
);
assert_eq!(code, 255);
assert!(String::from_utf8(stderr).unwrap().contains("[bad_request]"));
// Only the exec that got as far as the backend.
assert_eq!(
fixture.backend.calls.lock().unwrap().as_slice(),
["exec web-01 unreachable"]
);
}
#[test]
fn with_the_switch_off_nothing_reaches_the_backend() {
let fixture = fixture();
fixture.server.set_enabled(false);
let (code, stdout, stderr) = run(
&fixture.socket,
Request::Exec {
host: id(&fixture.web),
command: "true".into(),
},
false,
);
assert_eq!(code, 255);
assert!(stdout.is_empty());
let stderr = String::from_utf8(stderr).unwrap();
assert!(stderr.starts_with("shellrs: [not_enabled]"));
assert!(stderr.contains("设置 → 外部 CLI"));
assert!(fixture.backend.calls.lock().unwrap().is_empty());
}
#[test]
fn without_the_app_the_command_says_it_is_not_running() {
let dir = tempfile::tempdir().unwrap();
let (code, _, stderr) = run(
&cli_endpoint(dir.path()),
Request::List { query: None },
true,
);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [not_running]")
);
// Nor when a crashed app left its socket behind, with nobody on it.
#[cfg(unix)]
{
let stale = dir.path().join("stale.sock");
// No process may start meanwhile and keep the socket open.
let _forks = crate::testing::no_forks();
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
let (code, _, stderr) = run(&stale, Request::List { query: None }, true);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [not_running]")
);
}
}
#[test]
fn opening_shellrs_again_brings_the_running_one_forward() {
let fixture = fixture();
// Whatever 启用外部 CLI says: this is not the external CLI.
fixture.server.set_enabled(false);
assert_eq!(fixture.server.take_activation(), None);
assert!(client::activate_running_app(&fixture.socket, None));
assert_eq!(fixture.server.take_activation(), Some(Vec::new()));
// Heard once, acted on once.
assert_eq!(fixture.server.take_activation(), None);
assert!(fixture.backend.calls.lock().unwrap().is_empty());
// The switch still guards everything else.
let (code, _, stderr) = run(&fixture.socket, Request::List { query: None }, true);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [not_enabled]")
);
assert_eq!(fixture.server.take_activation(), None);
}
#[test]
fn opening_shellrs_with_links_hands_them_to_the_running_one_in_order() {
let fixture = fixture();
fixture.server.set_enabled(false);
let link = |url: &str, tab: Option<&str>| OpenLink {
url: url.into(),
tab: tab.map(str::to_string),
};
let first = link("ssh://token:p%40ss@172.16.0.28:12024", Some("堡垒机"));
let second = link("ssh://root@10.0.0.9", None);
assert!(client::activate_running_app(
&fixture.socket,
Some(first.clone())
));
assert!(client::activate_running_app(&fixture.socket, None));
assert!(client::activate_running_app(
&fixture.socket,
Some(second.clone())
));
assert_eq!(fixture.server.take_activation(), Some(vec![first, second]));
assert_eq!(fixture.server.take_activation(), None);
}
#[test]
fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
let mut store = HostStore::empty();
let saved = store.insert_unnotified(HostDraft::new(
"web",
"10.0.1.12",
22,
"root",
AuthKind::Password,
None,
));
let link = store.insert_external_unnotified(
SshLink::parse("ssh://token:secret@10.0.0.9:2222", Some("堡垒机")).unwrap(),
);
let targets = CliTarget::all(&store);
let ids: Vec<HostId> = targets.iter().map(|target| target.host().id).collect();
assert_eq!(ids, [saved, link]);
// With the link's password, which only memory holds.
assert_eq!(targets[1].login().password, SecretRef::temporary(link.0));
let fixture = fixture();
fixture.server.set_targets(targets);
let (code, stdout, _) = run(&fixture.socket, Request::List { query: None }, true);
assert_eq!(code, 0);
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
let listed: Vec<(&str, bool)> = hosts
.iter()
.map(|host| (host.name.as_str(), host.temporary))
.collect();
assert_eq!(listed, [("web", false), ("堡垒机", true)]);
let temporary = store.host(link).unwrap().public_id.to_string();
assert_eq!(hosts[1].id, temporary);
assert_eq!(hosts[1].address(), "token@10.0.0.9:2222");
// Reached by that ID like a saved host.
let (code, _, _) = run(
&fixture.socket,
Request::Exec {
host: temporary,
command: "uptime".into(),
},
false,
);
assert_eq!(code, 3);
// A table says so where the group goes.
let (_, table, _) = run(&fixture.socket, Request::List { query: None }, false);
assert!(
table.lines().nth(2).unwrap().contains("(外部连接)"),
"{table}"
);
}
#[test]
fn an_older_apps_list_reads_as_saved_hosts() {
let host: HostInfo = serde_json::from_str(
r#"{"id":"VmLkf1snMOuPKJ07","name":"web","group":null,"user":"root","host":"10.0.1.12","port":22,"os":null}"#,
)
.unwrap();
assert!(!host.temporary);
}
#[test]
fn with_nothing_running_shellrs_is_free_to_start() {
let dir = tempfile::tempdir().unwrap();
assert!(!client::activate_running_app(
&cli_endpoint(dir.path()),
None
));
// Nor does a socket left behind by a crash count as a running app.
#[cfg(unix)]
{
let stale = dir.path().join("stale.sock");
// No process may start meanwhile and keep the socket open.
let _forks = crate::testing::no_forks();
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
assert!(!client::activate_running_app(&stale, None));
}
}
#[test]
fn a_transfer_reports_its_summary_and_exits_1_when_items_failed() {
let fixture = fixture();
let (code, stdout, _) = run(
&fixture.socket,
Request::Upload {
host: id(&fixture.web),
source: local("dist"),
destination: "~/dist".into(),
},
true,
);
assert_eq!(code, 1);
let summary: TransferSummary = serde_json::from_str(&stdout).unwrap();
assert_eq!(summary.files, 1);
assert_eq!(summary.failures, ["无法上传:permission denied"]);
assert_eq!(
fixture.backend.calls.lock().unwrap().as_slice(),
[format!("upload web-01 {} ~/dist", local("dist").display())]
);
}
#[test]
fn progress_is_rewritten_in_place_without_escape_sequences() {
let fixture = fixture();
let (code, _, stderr) = run_on(
&fixture.socket,
Request::Upload {
host: id(&fixture.web),
source: local("dist"),
destination: "~/dist".into(),
},
false,
true,
);
assert_eq!(code, 1);
let stderr = String::from_utf8(stderr).unwrap();
// Shown, then blanked with spaces before the failures are listed; the
// old Windows console would print an escape sequence as it is.
let line = "1/2 个文件,10 B / 20 B";
let blank = " ".repeat(unicode_width::UnicodeWidthStr::width(line));
assert_eq!(
stderr,
format!("\r{line}\r{blank}\r无法上传:permission denied\n")
);
}
#[test]
fn the_socket_belongs_to_one_app_at_a_time_and_goes_with_it() {
let fixture = fixture();
let second = CliServer::start(fixture.socket.clone(), fixture.backend.clone());
assert_eq!(
second.err().map(|error| error.kind()),
Some(io::ErrorKind::AddrInUse)
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&fixture.socket)
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
let socket = fixture.socket.clone();
drop(fixture.server);
let (code, _, stderr) = run(&socket, Request::List { query: None }, true);
assert_eq!(code, 255);
assert!(
String::from_utf8(stderr)
.unwrap()
.starts_with("shellrs: [not_running]")
);
#[cfg(unix)]
{
assert!(!socket.exists());
// A socket left behind by a crash is taken over.
std::fs::write(&socket, "").unwrap();
}
let restarted = CliServer::start(socket.clone(), Arc::new(FakeBackend::default()));
assert!(restarted.is_ok());
}
#[test]
fn exec_takes_its_command_as_one_argument_or_from_stdin() {
let parse = |args: &[&str]| Cli::try_parse_from(args).map(|cli| cli.command);
assert!(matches!(
parse(&["shellrs", "exec", "ID", "df -h | head"]),
Ok(Command::Exec { command: Some(command), stdin: false, .. }) if command == "df -h | head"
));
assert!(matches!(
parse(&["shellrs", "exec", "ID", "--stdin"]),
Ok(Command::Exec {
command: None,
stdin: true,
..
})
));
// Neither, or both.
assert!(parse(&["shellrs", "exec", "ID"]).is_err());
assert!(parse(&["shellrs", "exec", "ID", "ls", "--stdin"]).is_err());
// Split words are a mistake, not a longer command.
assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err());
}
#[test]
fn a_command_reaches_the_remote_shell_with_unix_line_ends() {
assert_eq!(
normalize_command("\u{feff}cd /srv\r\nls -l\r\n"),
"cd /srv\nls -l\n"
);
assert_eq!(normalize_command("printf 'a\\r\\n'"), "printf 'a\\r\\n'");
assert_eq!(normalize_command("uname -a"), "uname -a");
}
/// What a Windows console gets for `bytes` written in the given pieces.
fn console_text(pieces: &[&[u8]]) -> String {
let mut text = ConsoleText {
inner: Vec::new(),
lossy: true,
pending: Vec::new(),
};
for piece in pieces {
io::Write::write_all(&mut text, piece).unwrap();
}
text.finish().unwrap();
String::from_utf8(text.inner).unwrap()
}
#[test]
fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() {
let word = "生产".as_bytes();
// A character cut in two by the frames it came in.
assert_eq!(console_text(&[&word[..2], &word[2..]]), "生产");
// GBK for 中, and a stray byte.
assert_eq!(
console_text(&[b"a\xd6\xd0b", b"\xff"]),
"a\u{fffd}\u{fffd}b\u{fffd}"
);
// Cut off for good at the end.
assert_eq!(console_text(&[b"ok", &word[..1]]), "ok\u{fffd}");
// Anywhere but a Windows console, bytes are passed on as they are.
let mut raw = ConsoleText::new(Vec::new(), cfg!(not(windows)));
io::Write::write_all(&mut raw, b"\xff\xd6").unwrap();
raw.finish().unwrap();
assert_eq!(raw.inner, b"\xff\xd6");
}