552 lines
18 KiB
Rust
552 lines
18 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 Ok(Reply::Host(created)) = apply(cx, handle, &workspace, CliChange::CreateHost(nameless))
|
|
else {
|
|
panic!("host without a name not created");
|
|
};
|
|
assert_eq!(created.name, "10.0.9.2");
|
|
let error = apply(
|
|
cx,
|
|
handle,
|
|
&workspace,
|
|
CliChange::CreateHost(HostFields::default()),
|
|
)
|
|
.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());
|
|
let before = factory.writes.lock().unwrap().len();
|
|
assert_eq!(start_run(&workspace, &web, &first, cx), Ok(()));
|
|
let (second, _) = TerminalRun::new("true".into());
|
|
assert_eq!(
|
|
start_run(&workspace, &web, &second, cx),
|
|
Err(RunFailure::Busy(BusyReason::AnotherRun))
|
|
);
|
|
// 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()))
|
|
);
|
|
// The probe reply follows the writes in the fake transport's FIFO.
|
|
// Checking now includes the first run's legitimate Ctrl-C and still
|
|
// rejects any extra write from the refused second run.
|
|
let writes = factory.writes.lock().unwrap()[before..].to_vec();
|
|
assert_eq!(writes.len(), 2);
|
|
assert_eq!(writes[0].as_slice(), b"\x03");
|
|
let probe = std::str::from_utf8(&writes[1]).unwrap();
|
|
assert_eq!(probe.matches(" sh -c '").count(), 1);
|
|
assert!(probe.contains("6973;P;"));
|
|
|
|
// 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"));
|
|
}
|