Files
openidea/crates/ide-host/tests/remote_plugins.rs
l0ng-ai 3b78d15d6d refactor(plugins): drop the Kubernetes plugin, samples become test fixtures
- Remove the Kubernetes official plugin.
- `hello` and `language-toy` move from `plugins/` to
  `crates/ide-plugin/tests/fixtures/`: they stay what the host's tests run
  against, but are no longer listed as plugins of the repository.
- A `jobs` fixture takes over what only Kubernetes exercised: a tree view
  filled by a pinned program, a terminal for the activated entry in the
  invoking window, string and number settings, locally and in remote
  projects.
- `plugins/` now holds only the official plugins; `build.sh`, CI and the
  debug plugin catalog follow.
2026-11-04 02:41:46 +08:00

305 lines
14 KiB
Rust

//! Code plugins of a remote project run on the
//! server. The desktop's plugin host syncs its live
//! code plugins over the remote protocol; Checkstyle then checks the file on
//! the server's disk when it is saved, and secrets come from the desktop.
#![cfg(unix)]
use ide_core::IdeEvent;
use ide_host::plugins::remote_host::{
EVENT_NOTIFICATION, PackageSyncReport, PluginEventMessage, RemotePlugins, SECRET_REQUEST, SYNC_PACKAGES,
SecretRequest,
};
use ide_host::plugins::{Consent, PluginEvent, PluginHost, PluginStatus, WorkbenchEvent};
use ide_host::rpc::{self, Dispatcher, EventMessage, Invoke};
use ide_host::{Host, HostConfig};
use ide_jsonrpc::{Handler, Peer};
use ide_plugin::Limits;
use parking_lot::Mutex;
use serde_json::{Value, json};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
#[derive(Default)]
struct Client {
events: Mutex<Vec<EventMessage>>,
plugin_events: Mutex<Vec<PluginEventMessage>>,
secret_requests: Mutex<Vec<SecretRequest>>,
}
impl Handler for Client {
fn on_notification(&self, method: &str, params: Value) {
match method {
"event" => self.events.lock().push(serde_json::from_value(params).unwrap()),
EVENT_NOTIFICATION => self.plugin_events.lock().push(serde_json::from_value(params).unwrap()),
_ => {}
}
}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
if method == SECRET_REQUEST {
let r: SecretRequest = serde_json::from_value(params).unwrap();
let value = (r.name == "api-key").then(|| "sk-desktop".to_owned());
self.secret_requests.lock().push(r);
return Ok(serde_json::to_value(value).unwrap());
}
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result<Value, ide_core::IdeError> {
let call = Invoke { window: "main".into(), cmd: cmd.into(), args };
peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply)
}
async fn sync(peer: &Peer, desktop: &PluginHost, files: &[&str]) -> PackageSyncReport {
let files: serde_json::Map<String, Value> = files
.iter()
.map(|id| ((*id).to_owned(), serde_json::to_value(desktop.remote_package_files(id).unwrap()).unwrap()))
.collect();
let args = json!({ "packages": desktop.remote_packages(), "files": files });
serde_json::from_value(invoke(peer, SYNC_PACKAGES, args).await.unwrap()).unwrap()
}
fn official(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/official").join(name)
}
/// A test fixture plugin (`crates/ide-plugin/tests/fixtures/<name>`).
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures").join(name)
}
/// A `checkstyle` that reports a tab on line 2 of the file it is given.
fn fake_checkstyle(bin: &Path) {
std::fs::create_dir_all(bin).unwrap();
std::fs::write(
bin.join("checkstyle"),
"#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q '\t' \"$file\" || { echo '<checkstyle/>'; exit 0; }\n\
echo '<?xml version=\"1.0\"?><checkstyle><file name=\"'\"$file\"'\">'\n\
echo '<error line=\"2\" column=\"1\" severity=\"warning\" message=\"Line contains a tab character.\" source=\"com.puppycrawl.tools.checkstyle.checks.whitespace.FileTabCharacterCheck\"/>'\n\
echo '</file></checkstyle>'\nexit 1\n",
)
.unwrap();
std::fs::set_permissions(bin.join("checkstyle"), std::fs::Permissions::from_mode(0o755)).unwrap();
}
async fn until(what: &str, secs: u64, mut done: impl FnMut() -> bool) {
for _ in 0..secs * 50 {
if done() {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("timed out waiting for {what}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
fake_checkstyle(&bin);
let path_var = {
let bin = bin.clone();
Arc::new(move || Some(bin.clone().into_os_string()))
};
// The desktop: Checkstyle and AI Completion installed and granted.
let desktop =
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for name in ["checkstyle", "ai-completion"] {
let info = desktop.install_from_dir(&official(name), &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
// The server, with the project on "its" disk.
// Canonical, as the UI reports paths (macOS: /var → /private/var).
let project = tmp.path().join("remote/demo");
std::fs::create_dir_all(project.join("src")).unwrap();
let project = std::fs::canonicalize(project).unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let sink_peer = peer_slot.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
plugins.host().set_program_path(path_var);
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
// Digests first; then the files of what the server lacks; then nothing to do.
let first = sync(&peer, &desktop, &[]).await;
assert_eq!(first.missing, ["openidea.ai-completion", "openidea.checkstyle"], "{first:?}");
let second = sync(&peer, &desktop, &["openidea.ai-completion", "openidea.checkstyle"]).await;
assert_eq!(second.installed.len(), 2, "{second:?}");
assert!(second.failed.is_empty(), "{second:?}");
assert_eq!(sync(&peer, &desktop, &[]).await, PackageSyncReport::default());
let remote = plugins.host().info_of("openidea.checkstyle").unwrap();
assert_eq!(remote.status, PluginStatus::Inactive, "{:?}", remote.error);
assert!(remote.capabilities.iter().any(|c| c.id == "process:checkstyle" && c.granted));
// The remote window's UI routes the run-time commands to the server.
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
// Saving a file that exists only on the server: Checkstyle reads that file.
let java = project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned());
invoke(&peer, "plugin_post_events", json!({ "events": [saved] })).await.unwrap();
let published = || {
client.events.lock().iter().find_map(|m| match &m.event {
IdeEvent::Diagnostics { path, source, items } if source == "plugin:openidea.checkstyle" => {
Some((path.0.clone(), items.clone()))
}
_ => None,
})
};
until("remote checkstyle diagnostics", 20, || published().is_some()).await;
let (path, items) = published().unwrap();
assert_eq!(path, java);
assert_eq!((items.len(), items[0].range.start.line), (1, 1));
// Secrets stay on the desktop: the server asks for them when used.
let secrets = plugins.host().secrets("openidea.ai-completion").unwrap();
assert_eq!(secrets, [("api-key".to_owned(), true)]);
assert_eq!(client.secret_requests.lock()[0].plugin, "openidea.ai-completion");
// A command of a remote plugin, with this window as its client.
invoke(
&peer,
"plugin_run_command",
json!({ "pluginId": "openidea.ai-completion", "commandId": "toggle", "arg": null, "editor": null }),
)
.await
.unwrap();
until("the command's notification", 5, || {
client
.plugin_events
.lock()
.iter()
.any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::Notification { .. }))
})
.await;
// Disabled on the desktop: gone from the server.
desktop.set_enabled("openidea.checkstyle", false).unwrap();
let removed = sync(&peer, &desktop, &[]).await;
assert_eq!(removed.removed, ["openidea.checkstyle"]);
assert!(plugins.host().info_of("openidea.checkstyle").is_err());
plugins.host().shutdown();
}
/// CSV Editor and a tool window with a terminal (the `jobs` fixture) in a
/// remote project. The table edits the document on the server; the jobs are
/// the server's `jobs` program's, and the terminal opens in the remote window.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn remote_projects_get_plugin_views_and_custom_editors() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("jobs"), "#!/bin/sh\n[ \"$1\" = list ] && echo remote-job\n").unwrap();
std::fs::set_permissions(bin.join("jobs"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path_var = {
let bin = bin.clone();
Arc::new(move || Some(bin.clone().into_os_string()))
};
let desktop =
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
.unwrap();
desktop.set_program_path(path_var.clone());
for dir in [official("csv-editor"), fixture("jobs")] {
let info = desktop.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
}
let project = tmp.path().join("remote/demo");
std::fs::create_dir_all(&project).unwrap();
let project = std::fs::canonicalize(project).unwrap();
std::fs::write(project.join("people.csv"), "name,city\nAda,London\n").unwrap();
let base = tmp.path().join("server");
let peer_slot = Arc::new(OnceLock::new());
let sink_peer = peer_slot.clone();
let host = Arc::new(Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
plugins.host().set_program_path(path_var);
let mut dispatcher = Dispatcher::standard();
plugins.register(&mut dispatcher);
let (client_io, server_io) = tokio::io::duplex(1 << 20);
let (sr, sw) = tokio::io::split(server_io);
tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
let (cr, cw) = tokio::io::split(client_io);
let client = Arc::new(Client::default());
let peer = Peer::connect(cr, cw, client.clone());
let _: Value = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
invoke(&peer, "project_trust_set", json!({ "path": project, "trusted": true, "folder": false })).await.unwrap();
let report = sync(&peer, &desktop, &["openidea.csv-editor", "jobs"]).await;
assert_eq!(report.installed.len(), 2, "{report:?}");
// CSV Editor: the document is the server's.
let opened = invoke(&peer, "doc_open", json!({ "path": project.join("people.csv") })).await.unwrap();
let doc = opened["id"].as_u64().expect("doc id");
let args = |extra: Value| {
let mut a = json!({ "pluginId": "openidea.csv-editor", "view": "table", "doc": doc });
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
a
};
let view =
invoke(&peer, "plugin_view_render", args(json!({ "viewport": { "firstRow": 0, "rows": 50 } }))).await.unwrap();
assert_eq!(view["body"]["kind"], "table");
assert_eq!(view["body"]["value"]["rows"], json!([["Ada", "London"]]));
let cell = json!({ "event": { "kind": "cell", "row": 0, "column": 1, "value": "Oslo" } });
invoke(&peer, "plugin_view_event", args(cell)).await.unwrap();
let snap = invoke(&peer, "doc_snapshot", json!({ "doc": doc })).await.unwrap();
assert_eq!(snap["text"], "name,city\nAda,Oslo\n");
// Jobs: the server's program; the terminal opens in this window.
let jobs = |event: Value| json!({ "pluginId": "jobs", "view": "jobs", "doc": null, "event": event });
let view =
invoke(&peer, "plugin_view_render", json!({ "pluginId": "jobs", "view": "jobs", "doc": null })).await.unwrap();
assert_eq!(view["body"]["value"][0]["label"], "remote-job", "{view}");
invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "select", "key": "remote-job" }))).await.unwrap();
invoke(&peer, "plugin_view_event", jobs(json!({ "kind": "button", "id": "follow" }))).await.unwrap();
until("the job's terminal", 5, || {
client.plugin_events.lock().iter().any(|m| {
m.window.as_deref() == Some("main")
&& matches!(&m.event, PluginEvent::OpenTerminal { program, .. } if program.0.starts_with(bin.canonicalize().unwrap()))
})
})
.await;
plugins.host().shutdown();
}