дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
879 рядки
41 KiB
Rust
879 рядки
41 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>>,
|
|
desktop: Option<Arc<PluginHost>>,
|
|
}
|
|
|
|
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());
|
|
}
|
|
if let Some(desktop) = &self.desktop {
|
|
use ide_host::plugins::remote_host::{AUTH_REQUEST, SECRET_WRITE};
|
|
if method == AUTH_REQUEST {
|
|
return Ok(
|
|
serde_json::to_value(desktop.remote_auth("main", serde_json::from_value(params).unwrap())).unwrap()
|
|
);
|
|
}
|
|
if method == SECRET_WRITE {
|
|
return Ok(
|
|
serde_json::to_value(desktop.remote_secret_write(serde_json::from_value(params).unwrap())).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}");
|
|
|
|
invoke(&peer, "plugin_set_ui_locale", json!({ "tag": "fr" })).await.unwrap();
|
|
assert_eq!(plugins.host().ui_locale(), "fr");
|
|
|
|
// 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: retained but stopped on the server.
|
|
desktop.set_enabled("openidea.checkstyle", false).unwrap();
|
|
let removed = sync(&peer, &desktop, &[]).await;
|
|
assert!(removed.removed.is_empty());
|
|
assert_eq!(plugins.host().info_of("openidea.checkstyle").unwrap().status, PluginStatus::Disabled);
|
|
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"), fixture("settings-pages")] {
|
|
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", "settings-pages"]).await;
|
|
assert_eq!(report.installed.len(), 3, "{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;
|
|
|
|
invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({ "pluginId": "jobs", "commandId": "context", "arg": "mode=true", "editor": null }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
until("the context key's event", 5, || {
|
|
client.plugin_events.lock().iter().any(|m| {
|
|
m.window.as_deref() == Some("main")
|
|
&& matches!(&m.event, PluginEvent::ContextChanged { key, value, .. } if key == "mode" && value.as_deref() == Some("true"))
|
|
})
|
|
})
|
|
.await;
|
|
let keys = invoke(&peer, "plugin_context_keys", json!({})).await.unwrap();
|
|
assert_eq!(keys, json!([{ "pluginId": "jobs", "key": "mode", "value": "true" }]), "{keys}");
|
|
|
|
let selected = invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({ "pluginId": "jobs", "commandId": "selection", "arg": null, "editor": null, "paths": ["/srv/src/a.txt"] }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(selected, "/srv/src/a.txt");
|
|
for (scope, name) in [("global", "Global"), ("project", "Project")] {
|
|
let values = json!({ "settings-pages": { "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] } });
|
|
invoke(&peer, "settings_set", json!({ "scope": scope, "key": "plugins", "value": values.to_string() }))
|
|
.await
|
|
.unwrap();
|
|
}
|
|
for (scope, name) in
|
|
[(None, "Project"), (Some("global"), "Global"), (Some("project"), "Project"), (None, "Project")]
|
|
{
|
|
let mut args = json!({ "pluginId": "settings-pages", "commandId": "test", "arg": null, "editor": null });
|
|
if let Some(scope) = scope {
|
|
args["settingsScope"] = json!(scope);
|
|
}
|
|
let result = invoke(&peer, "plugin_run_command", args).await.unwrap();
|
|
let result: Value = serde_json::from_str(result.as_str().unwrap()).unwrap();
|
|
assert_eq!(result["profiles"][0]["name"], name);
|
|
assert_eq!(result["hasProject"], true);
|
|
}
|
|
|
|
plugins.host().shutdown();
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let desktop = Arc::new(
|
|
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
|
.unwrap(),
|
|
);
|
|
desktop.install_from_dir(&fixture("api14"), &Consent::all()).unwrap();
|
|
let project = tmp.path().join("project");
|
|
std::fs::create_dir(&project).unwrap();
|
|
let project = project.canonicalize().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.into()))),
|
|
owner: |w| w,
|
|
runtime: tokio::runtime::Handle::current(),
|
|
project_environment: false,
|
|
}));
|
|
let plugins = RemotePlugins::start(&base.join("data"), &host, peer_slot.clone()).unwrap();
|
|
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);
|
|
let server = tokio::spawn(rpc::serve(host.clone(), dispatcher, peer_slot, sr, sw));
|
|
let (cr, cw) = tokio::io::split(client_io);
|
|
let client = Arc::new(Client { desktop: Some(desktop.clone()), ..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();
|
|
sync(&peer, &desktop, &[]).await;
|
|
let report = sync(&peer, &desktop, &["api14"]).await;
|
|
assert!(report.failed.is_empty(), "{report:?}");
|
|
let run = |command: &str, arg: Option<&str>| json!({ "pluginId": "api14", "commandId": command, "arg": arg, "editor": null });
|
|
let remote_file = project.join("remote.txt");
|
|
std::fs::write(&remote_file, "remote text").unwrap();
|
|
invoke(&peer, "mcp_set_open_editors", json!({"paths": [remote_file]})).await.unwrap();
|
|
invoke(&peer, "mcp_set_focused_editor", json!({"editor": {"path": remote_file, "selection": {"start": {"line": 0, "col": 0}, "end": {"line": 0, "col": 0}}, "selectedText": ""}, "focused": true})).await.unwrap();
|
|
let editors = invoke(&peer, "plugin_run_command", run("open-editors", None)).await.unwrap();
|
|
assert!(editors.as_str().unwrap().contains("remote.txt"));
|
|
assert!(editors.as_str().unwrap().contains("active: true"));
|
|
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
|
assert_eq!(problems.as_str(), Some("[]"));
|
|
let other_file = project.join("other.txt");
|
|
std::fs::write(&other_file, "other text").unwrap();
|
|
let outside_file = tmp.path().join("outside.txt");
|
|
std::fs::write(&outside_file, "outside").unwrap();
|
|
let problem = |path: &Path, message: &str| {
|
|
json!({"path": path, "items": [{
|
|
"range": {"start": {"line": 0, "col": 1}, "end": {"line": 0, "col": 3}},
|
|
"severity": "warning", "message": message, "source": "remote-check", "code": null
|
|
}]})
|
|
};
|
|
let files = json!([
|
|
problem(&remote_file, "remote warning"),
|
|
problem(&other_file, "other warning"),
|
|
problem(&outside_file, "outside warning")
|
|
]);
|
|
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
|
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
|
let problems = problems.as_str().unwrap();
|
|
assert!(problems.contains("remote warning"), "{problems}");
|
|
assert!(problems.contains("other warning"), "{problems}");
|
|
assert!(!problems.contains("outside warning"), "{problems}");
|
|
for path in ["remote.txt", remote_file.to_str().unwrap()] {
|
|
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
|
|
let problems = problems.as_str().unwrap();
|
|
assert!(problems.contains("remote warning"), "{problems}");
|
|
assert!(!problems.contains("other warning"), "{problems}");
|
|
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
|
}
|
|
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
|
|
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
|
|
.await
|
|
.unwrap();
|
|
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
|
assert!(!problems.as_str().unwrap().contains("remote warning"));
|
|
assert!(problems.as_str().unwrap().contains("other warning"));
|
|
invoke(
|
|
&peer,
|
|
"mcp_report_problems",
|
|
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
|
assert!(problems.as_str().unwrap().contains("replacement warning"));
|
|
assert!(!problems.as_str().unwrap().contains("other warning"));
|
|
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
|
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
|
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
|
|
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
|
|
let sets = invoke(&peer, "plugin_decoration_sets", json!({})).await.unwrap();
|
|
assert_eq!(sets["api14"][0]["root"], project.to_str().unwrap());
|
|
until("forwarded editor and decoration events", 5, || {
|
|
let events = client.plugin_events.lock();
|
|
events.iter().any(|m| m.window.as_deref() == Some("main") && matches!(m.event, PluginEvent::EditorOpen { .. }))
|
|
&& events.iter().any(|m| matches!(m.event, PluginEvent::DecorationsChanged { .. }))
|
|
})
|
|
.await;
|
|
invoke(&peer, "plugin_run_command", run("secret", Some("remote-token"))).await.unwrap();
|
|
assert_eq!(desktop.declared_secret("api14", "token").as_deref(), Some("remote-token"));
|
|
invoke(&peer, "plugin_run_command", run("delete-secret", None)).await.unwrap();
|
|
assert_eq!(desktop.declared_secret("api14", "token"), None);
|
|
invoke(&peer, "plugin_run_command", run("window-status", Some("remote"))).await.unwrap();
|
|
let status = invoke(&peer, "plugin_status_items", json!({})).await.unwrap();
|
|
assert!(status.as_array().unwrap().iter().any(|s| s["window"] == true && s["item"]["text"] == "remote"));
|
|
let oauth_peer = peer.clone();
|
|
let oauth = tokio::spawn(async move {
|
|
invoke(
|
|
&oauth_peer,
|
|
"plugin_run_command",
|
|
json!({ "pluginId": "api14", "commandId": "oauth", "arg": null, "editor": null }),
|
|
)
|
|
.await
|
|
});
|
|
until("remote OAuth URI", 5, || {
|
|
client
|
|
.plugin_events
|
|
.lock()
|
|
.iter()
|
|
.any(|m| matches!(&m.event, PluginEvent::OutputChanged { channel, .. } if channel == "oauth"))
|
|
})
|
|
.await;
|
|
let uri = client
|
|
.plugin_events
|
|
.lock()
|
|
.iter()
|
|
.find_map(|m| match &m.event {
|
|
PluginEvent::OutputChanged { channel, text: Some(text), .. } if channel == "oauth" => Some(text.clone()),
|
|
_ => None,
|
|
})
|
|
.unwrap();
|
|
let authority = uri.strip_prefix("http://").unwrap().split('/').next().unwrap();
|
|
let mut stream = tokio::net::TcpStream::connect(authority).await.unwrap();
|
|
stream.write_all(b"GET /oauth/callback?code=desktop%2Bcode HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap();
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response).await.unwrap();
|
|
assert!(response.contains("You can close this tab"));
|
|
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
|
|
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
|
invoke(&peer, "close_project", json!({})).await.unwrap();
|
|
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
|
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
|
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
|
peer.kill();
|
|
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
|
|
let plugin_host = plugins.host().clone();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
plugin_host
|
|
.run_command(
|
|
"api14",
|
|
"problems",
|
|
None,
|
|
ide_plugin::Invocation {
|
|
client: Some("main".into()),
|
|
workspace_root: Some(project),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap()
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.as_deref(), Some("[]"));
|
|
plugins.host().shutdown();
|
|
desktop.shutdown();
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution_host() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let bin = tmp.path().join("bin");
|
|
std::fs::create_dir(&bin).unwrap();
|
|
let executable = bin.join("execution-tool");
|
|
std::fs::write(&executable, "#!/bin/sh\nif [ \"$1\" = wrapped ]; then printf '<%s>\\n' \"$@\" > \"$PWD/wrapped.txt\"; exit; fi\nread line\nprintf '%s\\n' \"$line\"\n").unwrap();
|
|
std::fs::set_permissions(&executable, 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());
|
|
desktop.install_from_dir(&fixture("execution"), &Consent::all()).unwrap();
|
|
let project = tmp.path().join("project");
|
|
std::fs::create_dir(&project).unwrap();
|
|
let project = project.canonicalize().unwrap();
|
|
std::fs::write(project.join("execution.marker"), "").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.into()))),
|
|
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, 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();
|
|
sync(&peer, &desktop, &[]).await;
|
|
assert!(sync(&peer, &desktop, &["execution"]).await.failed.is_empty());
|
|
invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({"pluginId":"execution", "commandId":"target", "arg":null, "editor":null}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let targets = invoke(&peer, "plugin_run_targets", json!({})).await.unwrap();
|
|
assert_eq!(targets.as_array().unwrap().len(), 1);
|
|
assert_eq!(targets[0]["pluginId"], "execution");
|
|
assert!(desktop.run_targets(&project).is_empty());
|
|
let id = targets[0]["id"].as_str().unwrap();
|
|
let settings = invoke(&peer, "plugin_answer_run_target", json!({"id":id, "useTarget":false})).await.unwrap();
|
|
assert_eq!(settings["run"]["defaultTarget"], "");
|
|
let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id});
|
|
invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap();
|
|
until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await;
|
|
assert_eq!(
|
|
std::fs::read_to_string(project.join("wrapped.txt")).unwrap(),
|
|
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
|
|
);
|
|
assert!(!project.join("bad").exists());
|
|
invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({"pluginId":"execution", "commandId":"untarget", "arg":null, "editor":null}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(invoke(&peer, "plugin_run_targets", json!({})).await.unwrap(), json!([]));
|
|
client.plugin_events.lock().clear();
|
|
client.events.lock().clear();
|
|
let runner = peer.clone();
|
|
let run = tokio::spawn(async move {
|
|
invoke(
|
|
&runner,
|
|
"plugin_run_command",
|
|
json!({"pluginId": "execution", "commandId": "terminal", "arg": null, "editor": null}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
});
|
|
until("terminal request", 5, || {
|
|
client.plugin_events.lock().iter().any(|m| matches!(m.event, PluginEvent::WindowRequest { .. }))
|
|
})
|
|
.await;
|
|
let (request_id, operation) = client
|
|
.plugin_events
|
|
.lock()
|
|
.iter()
|
|
.find_map(|m| match &m.event {
|
|
PluginEvent::WindowRequest {
|
|
request_id,
|
|
request: ide_host::plugins::WindowRequest::Terminal { operation, .. },
|
|
..
|
|
} => {
|
|
assert_eq!(m.window.as_deref(), Some("main"));
|
|
Some((request_id.clone(), operation.clone()))
|
|
}
|
|
_ => None,
|
|
})
|
|
.unwrap();
|
|
let terminal = invoke(
|
|
&peer,
|
|
"plugin_terminal_session_spawn",
|
|
json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 35}}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(terminal["id"].as_u64().is_some());
|
|
assert!(
|
|
invoke(
|
|
&peer,
|
|
"plugin_terminal_session_spawn",
|
|
json!({"operation": operation, "cols": 80, "rows": 24, "onOutput": {"$channel": 36}})
|
|
)
|
|
.await
|
|
.is_err()
|
|
);
|
|
invoke(
|
|
&peer,
|
|
"plugin_answer_command",
|
|
json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(run.await.unwrap(), operation);
|
|
invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({"pluginId": "execution", "commandId": "write", "arg": "finished\n", "editor": null}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
until("PTY exit", 5, || client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::TerminalExited { .. })))
|
|
.await;
|
|
plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3));
|
|
plugins.host().wait_events(Duration::from_secs(5));
|
|
let received = invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap();
|
|
assert_eq!(received.as_array().unwrap().len(), 1);
|
|
assert_eq!(received[0]["kind"], "terminal-exited");
|
|
let data: Value = serde_json::from_str(received[0]["data"].as_str().unwrap()).unwrap();
|
|
assert_eq!(data["id"], operation);
|
|
assert_eq!(data["status"], 0);
|
|
assert_eq!(data["closed"], false);
|
|
|
|
client.plugin_events.lock().clear();
|
|
client.events.lock().clear();
|
|
let runner = peer.clone();
|
|
let run = tokio::spawn(async move {
|
|
invoke(
|
|
&runner,
|
|
"plugin_run_command",
|
|
json!({"pluginId": "execution", "commandId": "run", "arg": null, "editor": null}),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
});
|
|
until("run request", 5, || {
|
|
client.plugin_events.lock().iter().any(|m| {
|
|
matches!(&m.event, PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::Run { .. }, .. })
|
|
})
|
|
})
|
|
.await;
|
|
let (request_id, operation, config) = client
|
|
.plugin_events
|
|
.lock()
|
|
.iter()
|
|
.find_map(|m| match &m.event {
|
|
PluginEvent::WindowRequest {
|
|
request_id,
|
|
request: ide_host::plugins::WindowRequest::Run { operation, config, .. },
|
|
..
|
|
} => Some((request_id.clone(), operation.clone(), config.clone())),
|
|
_ => None,
|
|
})
|
|
.unwrap();
|
|
let info = invoke(&peer, "run_start", json!({"config": config, "onOutput": {"$channel": 37}})).await.unwrap();
|
|
invoke(
|
|
&peer,
|
|
"plugin_answer_command",
|
|
json!({"requestId": request_id, "answer": {"kind": "value", "value": null}}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(run.await.unwrap(), operation);
|
|
invoke(&peer, "run_stdin", json!({"id": info["id"], "data": "finished\n"})).await.unwrap();
|
|
until("run exit", 5, || client.events.lock().iter().any(|m| matches!(&m.event, IdeEvent::RunExited { .. }))).await;
|
|
plugins.host().flush_events_at(std::time::Instant::now() + Duration::from_secs(3));
|
|
plugins.host().wait_events(Duration::from_secs(5));
|
|
let received = invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({"pluginId": "execution", "commandId": "events", "arg": null, "editor": null}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let received: Value = serde_json::from_str(received.as_str().unwrap()).unwrap();
|
|
assert_eq!(received.as_array().unwrap().len(), 2);
|
|
assert_eq!(received[1]["kind"], "run-exited");
|
|
let data: Value = serde_json::from_str(received[1]["data"].as_str().unwrap()).unwrap();
|
|
assert_eq!(data["id"], operation);
|
|
assert_eq!(data["status"], 0);
|
|
assert_eq!(data["killed"], false);
|
|
plugins.host().shutdown();
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn api14_database_sources_credentials_and_disable_lifetime_run_on_the_server() {
|
|
ide_ssh::secrets::testing::use_memory_store();
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let id = "test.data-tools";
|
|
let desktop =
|
|
PluginHost::new(tmp.path().join("desktop/plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
|
.unwrap();
|
|
desktop.install_from_dir(&fixture("data-tools"), &Consent::all()).unwrap();
|
|
let project = tmp.path().join("project");
|
|
std::fs::create_dir(&project).unwrap();
|
|
let project = project.canonicalize().unwrap();
|
|
std::fs::write(project.join("data.sqlite"), "").unwrap();
|
|
let base = tmp.path().join("server");
|
|
let slot = Arc::new(OnceLock::new());
|
|
let sink = slot.clone();
|
|
let host = Arc::new(Host::new(HostConfig {
|
|
config_dir: base.join("config"),
|
|
data_dir: base.join("data"),
|
|
local_data_dir: base.join("local"),
|
|
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
|
syntax: Arc::default(),
|
|
sink_for: Box::new(move |w| rpc::event_sink(sink.clone(), Some(w.into()))),
|
|
owner: |w| w,
|
|
runtime: tokio::runtime::Handle::current(),
|
|
project_environment: false,
|
|
}));
|
|
let plugins = RemotePlugins::start(&base.join("data"), &host, slot.clone()).unwrap();
|
|
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, 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();
|
|
assert!(sync(&peer, &desktop, &[id]).await.failed.is_empty());
|
|
let source =
|
|
json!({"id":"local","name":"Remote local","driver":"sqlite","database":"data.sqlite","options":[]}).to_string();
|
|
invoke(&peer, "plugin_run_command", json!({"pluginId":id,"commandId":"upsert","arg":source,"editor":null}))
|
|
.await
|
|
.unwrap();
|
|
invoke(
|
|
&peer,
|
|
"plugin_run_command",
|
|
json!({"pluginId":id,"commandId":"credential","arg":"server-only-secret","editor":null}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let sources = invoke(&peer, "db_sources", json!({})).await.unwrap();
|
|
assert_eq!(sources[0]["pluginOwner"], id);
|
|
let internal = sources[0]["id"].as_str().unwrap();
|
|
let store = ide_db::secrets::SecretStore::new(base.join("data/db-passwords.json"), &project);
|
|
assert_eq!(store.get(internal).as_deref(), Some("server-only-secret"));
|
|
until("database source change", 5, || {
|
|
client.events.lock().iter().any(|m| matches!(m.event, IdeEvent::DbSourcesChanged))
|
|
})
|
|
.await;
|
|
desktop.set_enabled(id, false).unwrap();
|
|
let report = sync(&peer, &desktop, &[]).await;
|
|
assert!(report.removed.is_empty());
|
|
assert!(!invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty());
|
|
desktop.set_enabled(id, true).unwrap();
|
|
assert!(sync(&peer, &desktop, &[]).await.failed.is_empty());
|
|
let runner = peer.clone();
|
|
let connection = tokio::spawn(async move {
|
|
invoke(
|
|
&runner,
|
|
"plugin_run_command",
|
|
json!({"pluginId":"test.data-tools","commandId":"connect","arg":null,"editor":null}),
|
|
)
|
|
.await
|
|
});
|
|
until("database connect request", 5, || {
|
|
client.plugin_events.lock().iter().any(|m| {
|
|
matches!(
|
|
m.event,
|
|
PluginEvent::WindowRequest { request: ide_host::plugins::WindowRequest::DatabaseConnect { .. }, .. }
|
|
)
|
|
})
|
|
})
|
|
.await;
|
|
let request = client
|
|
.plugin_events
|
|
.lock()
|
|
.iter()
|
|
.find_map(|m| match &m.event {
|
|
PluginEvent::WindowRequest {
|
|
request_id,
|
|
request: ide_host::plugins::WindowRequest::DatabaseConnect { .. },
|
|
..
|
|
} => Some(request_id.clone()),
|
|
_ => None,
|
|
})
|
|
.unwrap();
|
|
invoke(&peer, "db_connect", json!({"id":internal})).await.unwrap();
|
|
invoke(&peer, "plugin_answer_command", json!({"requestId":request,"answer":{"kind":"value","value":null}}))
|
|
.await
|
|
.unwrap();
|
|
connection.await.unwrap().unwrap();
|
|
assert!(project.join("data.sqlite").exists());
|
|
desktop.uninstall(id).unwrap();
|
|
assert_eq!(sync(&peer, &desktop, &[]).await.removed, [id]);
|
|
assert!(invoke(&peer, "db_sources", json!({})).await.unwrap().as_array().unwrap().is_empty());
|
|
assert!(store.get(internal).is_none());
|
|
plugins.host().shutdown();
|
|
}
|