Files
openidea/crates/ide-host/tests/plugins.rs
l0ng-ai 2bf412ff0d feat(plugins): let plugin code open streaming log tabs (#2082)
views.open-log-tab(key, title, process-log) (SDK host::open_log_tab) opens the read-only log tab a web view opens with open-log-tab, from a command or event of a window, reusing the Services pane's process-log.

The host checks the process grant and limits when called and remembers what each key runs in that window; the tab starts it by key (plugin_log_tab_spawn) through the web-view PTY checks: pinned program, trusted project, session cap, lifecycle and grant revocation. Remote windows report that these tabs aren't available there yet.
2026-11-08 10:01:35 +08:00

5548 lines
256 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! End-to-end tests of the plugin host against real components.
//!
//! The components are checked in (`dist/plugin.wasm` of the official plugins
//! and of the fixtures in `crates/ide-plugin/tests/fixtures/`: `hello`, `jobs`,
//! `misbehave`) so tests need no wasm toolchain. Rebuild them with
//! `plugins/build.sh <dir>` after changing their source or `wit/`.
use ide_core::error::ErrorKind;
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
type Events = Arc<Mutex<Vec<(Option<String>, PluginEvent)>>>;
type EventReadGate = (std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>);
/// What plugins see of the test project: real `Documents`, as in the IDE.
struct TestWorkspace {
root: PathBuf,
other_roots: Mutex<Vec<PathBuf>>,
documents: Arc<ide_workspace::Documents>,
trusted: bool,
project_queries_left: Mutex<Option<usize>>,
event_read_gate: Mutex<Option<EventReadGate>>,
/// (producer, path) → published diagnostics.
diagnostics: Mutex<std::collections::BTreeMap<(String, PathBuf), Vec<ide_core::Diagnostic>>>,
/// plugin → its settings as the user set them.
settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
global_settings: Mutex<std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
}
impl TestWorkspace {
fn new(root: &Path) -> Arc<Self> {
let documents = ide_workspace::Documents::new(
Arc::new(ide_core::NullSink),
Box::new(|_: &Path, _: Option<&str>| ide_core::LanguageId::new("text")),
);
Arc::new(Self {
root: root.to_path_buf(),
other_roots: Mutex::default(),
documents: Arc::new(documents),
trusted: true,
project_queries_left: Mutex::new(None),
event_read_gate: Mutex::new(None),
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
})
}
/// Opens `rel` in an "editor": the invocation's focused editor.
fn open(&self, rel: &str, selections: &[(u32, u32, u32, u32)]) -> EditorContext {
let path = self.root.join(rel);
self.documents.open(&path).unwrap();
let selections = selections
.iter()
.map(|&(a, b, c, d)| ide_core::Range::new(ide_core::Pos::new(a, b), ide_core::Pos::new(c, d)))
.collect();
EditorContext { path, language: "text".into(), selections }
}
fn text(&self, rel: &str) -> String {
self.documents.text_of(&self.root.join(rel)).unwrap()
}
/// The user types at the start of the document.
fn type_text(&self, rel: &str, text: &str) {
let doc = self.documents.by_path(&self.root.join(rel)).unwrap();
let edit = ide_core::TextEdit { range: ide_core::Range::default(), new_text: text.into() };
self.documents.apply_edits(doc, &[edit]).unwrap();
}
}
impl PluginWorkspace for TestWorkspace {
fn projects(&self) -> Vec<PathBuf> {
std::iter::once(self.root.clone()).chain(self.other_roots.lock().iter().cloned()).collect()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
if path.ends_with("event-barrier.txt") {
let gate = self.event_read_gate.lock().take();
if let Some((entered, release)) = gate {
entered.send(()).unwrap();
release.recv_timeout(Duration::from_secs(10)).expect("release blocked event read");
}
}
self.documents.text_of(path)
}
fn buffer_dirty(&self, path: &Path) -> bool {
self.documents.by_path(path).is_some_and(|doc| self.documents.is_dirty(doc).unwrap_or(false))
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let snap = self.documents.snapshot(self.documents.by_path(path)?).ok()?;
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
let doc = self.documents.by_path(path).ok_or_else(|| HostError::NotFound("not open".into()))?;
match self.documents.apply_edits_at(doc, version as u32, edits) {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) => Err(HostError::Failed(e.message)),
}
}
fn trusted(&self, _root: &Path) -> bool {
self.trusted
}
fn project_of(&self, _window: &str) -> Option<PathBuf> {
if let Some(left) = self.project_queries_left.lock().as_mut() {
if *left == 0 {
return None;
}
*left -= 1;
}
Some(self.root.clone())
}
fn publish_diagnostics(
&self,
source: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
if !path.starts_with(&self.root) {
return Err(HostError::Invalid("not in the project".into()));
}
self.diagnostics.lock().insert((source.to_owned(), path.to_path_buf()), items);
Ok(())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let layer = if root.is_some() { &self.settings } else { &self.global_settings };
layer.lock().get(plugin).cloned().unwrap_or_default()
}
}
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
/// A test fixture plugin (`crates/ide-plugin/tests/fixtures/<name>`).
fn sample(name: &str) -> PathBuf {
repo().join("crates/ide-plugin/tests/fixtures").join(name)
}
fn theme_template() -> PathBuf {
repo().join("plugins/official/harbor-night")
}
fn misbehave_pkg() -> PathBuf {
repo().join("crates/ide-plugin/tests/fixtures/misbehave")
}
/// `misbehave` with `fs.read:<granted>` added to its capabilities.
fn misbehave_granted(dir: &Path, granted: &Path) -> PathBuf {
let pkg = dir.join("misbehave-granted");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
let path = granted.to_string_lossy().replace('\\', "/");
let required = format!("required = [\"project.read\", \"fs.read:{path}\"]");
let text = text.replace("required = [\"project.read\"]", &required);
std::fs::write(&manifest, text).unwrap();
pkg
}
/// `misbehave` requiring `capability` too, which is new in API 1.3.
fn misbehave_with(dir: &Path, name: &str, capability: &str) -> PathBuf {
let pkg = dir.join(name);
copy_dir(&misbehave_pkg(), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
let required = format!("required = [\"project.read\", \"{capability}\"]");
let text = text
.replace("required = [\"project.read\"]", &required)
.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"");
std::fs::write(&manifest, text).unwrap();
pkg
}
/// Whether `f` holds within 5 s. A call a slow runner keeps waiting behind
/// another (or behind a new instance starting) holds once it caught up.
fn eventually(mut f: impl FnMut() -> bool) -> bool {
let until = Instant::now() + Duration::from_secs(5);
while !f() {
if Instant::now() > until {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
true
}
/// A theme package with a light and a dark theme, plus licence files, in `dir`.
fn write_theme_pair(dir: &Path) {
std::fs::create_dir_all(dir.join("t")).unwrap();
std::fs::write(
dir.join("plugin.toml"),
"id = \"theme-pair\"\nname = \"Pair\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.themes]]\npath = \"t/day.json\"\n[[contributes.themes]]\npath = \"t/night.json\"\n",
)
.unwrap();
std::fs::write(
dir.join("t/day.json"),
r##"{"id":"day","name":"Day","kind":"light","tokens":{"--bg-frame":"#eeeeee"}}"##,
)
.unwrap();
std::fs::write(
dir.join("t/night.json"),
r##"{"id":"night","name":"Night","kind":"dark","tokens":{"--bg-frame":"#111111"}}"##,
)
.unwrap();
std::fs::write(dir.join("LICENSE"), "MIT License\n").unwrap();
std::fs::write(dir.join("NOTICE"), "Palette notice\n").unwrap();
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_name() == "target" {
continue;
}
if src.is_dir() { copy_dir(&src, &dst) } else { std::fs::copy(&src, &dst).map(|_| ()).unwrap() }
}
}
/// A copy of `hello` whose manifest is rewritten by `f`.
fn hello_variant(dir: &Path, f: impl Fn(&str) -> String) -> PathBuf {
let pkg = dir.join("hello");
copy_dir(&sample("hello"), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
std::fs::write(&manifest, f(&text)).unwrap();
pkg
}
/// `hello` requiring `required`, activated by `insert-date` only.
fn hello_02(text: &str, required: &str) -> String {
text.replace("activation = [\"on-command:*\", \"on-view:greetings\"]", "activation = [\"on-command:insert-date\"]")
.replace("required = [\"editor.read\", \"editor.write\"]", &format!("required = [{required}]"))
}
/// `hello` with a 0.1 manifest (`openidea-api`, `permissions`, no activation).
fn hello_01(text: &str) -> String {
// A Windows checkout may have CRLF line ends.
text.replace("\r\n", "\n")
.replace("engines.openidea = \"^1.0\"", "openidea-api = \"0.1\"")
.replace("activation = [\"on-command:*\", \"on-view:greetings\"]\n", "")
.replace(
"[capabilities]\nrequired = [\"editor.read\", \"editor.write\"]",
"permissions = [\"project-read\", \"editor-read\", \"editor-write\"]",
)
}
struct Fixture {
host: PluginHost,
workspace: Arc<TestWorkspace>,
events: Events,
_tmp: tempfile::TempDir,
plugins_dir: PathBuf,
project: PathBuf,
}
fn fixture(limits: Limits) -> Fixture {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
let project = tmp.path().join("project");
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
let project = dunce::canonicalize(project).unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
move |client: Option<&str>, e: PluginEvent| events.lock().push((client.map(str::to_owned), e))
};
let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), limits).unwrap();
let workspace = TestWorkspace::new(&project);
host.set_workspace(Some(workspace.clone()));
host.load_all();
host.startup_finished();
Fixture { host, workspace, events, _tmp: tmp, plugins_dir, project }
}
impl Fixture {
fn inv(&self) -> Invocation {
Invocation {
client: Some("main".into()),
workspace_root: Some(self.project.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
}
}
fn install(&self, dir: &Path) -> PluginInfo {
self.host.install_from_dir(dir, &Consent::all()).unwrap()
}
/// The installed copy of a plugin (its active version's directory).
fn installed(&self, id: &str) -> PathBuf {
self.host.info_of(id).unwrap().path.0
}
fn run(&self, plugin: &str, cmd: &str, arg: Option<&str>) -> Result<Option<String>, String> {
self.host.run_command(plugin, cmd, arg, self.inv()).map_err(|e| e.message)
}
/// Runs a command with `editor` focused.
fn run_in(&self, editor: &EditorContext, plugin: &str, cmd: &str) -> Result<Option<String>, String> {
let inv = Invocation { editor: Some(editor.clone()), ..self.inv() };
self.host.run_command(plugin, cmd, None, inv).map_err(|e| e.message)
}
fn status(&self, id: &str) -> PluginStatus {
self.host.info_of(id).unwrap().status
}
fn take_events(&self) -> Vec<(Option<String>, PluginEvent)> {
std::mem::take(&mut *self.events.lock())
}
/// Titles of the `commands` UI contributions of `plugin`.
fn command_titles(&self, plugin: &str) -> Vec<String> {
self.host
.ui_items(COMMANDS)
.into_iter()
.filter(|c| c.plugin_id == plugin)
.map(|c| c.decl["title"].as_str().unwrap().to_owned())
.collect()
}
fn theme_kinds(&self, plugin: &str) -> Vec<(String, ThemeKind)> {
self.host
.ui_items("themes")
.into_iter()
.filter(|c| c.plugin_id == plugin)
.map(|c| {
let t: ThemeDef = serde_json::from_value(c.decl).unwrap();
(t.id, t.kind)
})
.collect()
}
}
#[test]
fn hello_plugin_commands() {
let f = fixture(Limits::default());
let info = f.install(&sample("hello"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert!(f.installed("hello").join("plugin.toml").is_file());
assert!(f.installed("hello").join("dist/plugin.wasm").is_file());
assert_eq!(f.command_titles("hello"), ["Hello: Insert Date", "Hello: Word Count of Active File"]);
// Insert Date: one versioned edit replacing every selection (one undo step).
let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0), (1, 0, 1, 4)]);
let date = f.run_in(&editor, "hello", "insert-date").unwrap().unwrap();
assert!(date.len() == 10 && date.as_bytes()[4] == b'-' && date.starts_with("20"), "{date}");
assert_eq!(f.workspace.text("src/notes.txt"), format!("{date}one two three\n{date} five\n"));
assert_eq!(f.status("hello"), PluginStatus::Active, "activated by its command");
// Word Count reads the document, unsaved changes included.
let summary = f.run_in(&editor, "hello", "word-count").unwrap().unwrap();
assert!(summary.starts_with("5 words, 2 lines"), "{summary}");
assert!(f.take_events().iter().any(|(client, e)| client.as_deref() == Some("main")
&& matches!(e,
PluginEvent::Notification { title, level: NotificationLevel::Info, .. } if title == "Word count: notes.txt")));
// Without an editor it fails cleanly; guest errors don't disable the plugin.
assert!(f.run("hello", "word-count", None).unwrap_err().contains("no file is open"));
let inv = Invocation { editor: Some(editor.clone()), ..f.inv() };
let err = f.host.run_command("hello", "nope", None, inv).unwrap_err();
assert_eq!(err.kind, ErrorKind::NotFound, "{err:?}");
assert_eq!(f.status("hello"), PluginStatus::Active);
let stats = f.host.info_of("hello").unwrap().stats;
assert_eq!(stats.activations, 1);
// "no file is open" is the plugin failing; an unknown command is not.
assert!(stats.calls >= 4 && stats.errors == 1, "{stats:?}");
}
/// Edits name the document version they were computed for.
#[test]
fn edits_are_refused_when_the_document_changed() {
use ide_plugin::{HostApi, Runtime, RuntimeSetup};
let f = fixture(Limits::default());
f.install(&sample("hello"));
let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 3)]);
let doc = f.workspace.document(&editor.path).unwrap();
f.workspace.type_text("src/notes.txt", "typed ");
let edit = ide_core::TextEdit {
range: ide_core::Range::new(ide_core::Pos::new(0, 0), ide_core::Pos::new(0, 3)),
new_text: "ONE".into(),
};
assert_eq!(f.workspace.apply_edits(&editor.path, doc.0, std::slice::from_ref(&edit)), Err(HostError::Stale));
assert_eq!(f.workspace.text("src/notes.txt"), "typed one two three\nfour five\n");
let now = f.workspace.document(&editor.path).unwrap().0;
assert_eq!(f.workspace.apply_edits(&editor.path, now, &[edit]), Ok(now + 1));
assert_eq!(f.workspace.text("src/notes.txt"), "ONEed one two three\nfour five\n");
let _ = (
std::any::type_name::<Runtime>(),
std::any::type_name::<RuntimeSetup>(),
std::any::type_name::<&dyn HostApi>(),
);
}
#[test]
fn read_file_is_confined_to_the_project() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
assert_eq!(f.run("misbehave", "read", Some("src/notes.txt")).unwrap().unwrap(), "one two three\nfour five\n");
let abs = f.project.join("src/notes.txt");
assert!(f.run("misbehave", "read", Some(abs.to_str().unwrap())).is_ok());
let err = f.run("misbehave", "read", Some("../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
// A missing path inside the project is `not-found`, like the mock reports.
let err = f.run("misbehave", "read", Some("src/missing.txt")).unwrap_err();
assert!(err.contains("not found"), "{err}");
// Without a project there is nothing to read.
let inv = Invocation::default();
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
assert!(err.contains("no project is open"), "{err}");
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
/// `fs.read` reaches the granted paths and nothing else, through the real
/// component: the same checks the runtime makes, against a real file system.
#[test]
fn fs_read_reaches_only_the_granted_paths() {
let f = fixture(Limits::default());
let shared = f._tmp.path().join("shared");
std::fs::create_dir_all(&shared).unwrap();
let notes = shared.join("notes.txt");
std::fs::write(&notes, "outside the project\n").unwrap();
// A sibling whose name starts with the grant's: not covered by it.
let sibling = f._tmp.path().join("shared-other");
std::fs::create_dir_all(&sibling).unwrap();
let other = sibling.join("notes.txt");
std::fs::write(&other, "not the same directory\n").unwrap();
let secret = f._tmp.path().join("secret.txt");
f.install(&misbehave_granted(f._tmp.path(), &shared));
assert_eq!(f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap().unwrap(), "outside the project\n");
let err = f.run("misbehave", "fsread", Some(secret.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "the refusal names what to grant: {err}");
let err = f.run("misbehave", "fsread", Some(other.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "a name prefix grants nothing: {err}");
let err = f.run("misbehave", "fsread", Some("notes.txt")).unwrap_err();
assert!(err.contains("absolute"), "{err}");
let err = f.run("misbehave", "fsread", Some(shared.to_str().unwrap())).unwrap_err();
assert!(err.contains("not a file"), "{err}");
let missing = shared.join("gone.txt");
let err = f.run("misbehave", "fsread", Some(missing.to_str().unwrap())).unwrap_err();
assert!(err.contains("no such file"), "{err}");
// A refusal is the plugin's to handle, like any other error it gets.
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
/// `fs.read-dir` lists a granted directory, sorted, and refuses the rest.
#[test]
fn fs_read_dir_lists_a_granted_directory() {
let f = fixture(Limits::default());
let shared = f._tmp.path().join("shared");
std::fs::create_dir_all(shared.join("zeta")).unwrap();
std::fs::write(shared.join("beta.txt"), "b").unwrap();
std::fs::write(shared.join("alpha.txt"), "a").unwrap();
f.install(&misbehave_granted(f._tmp.path(), &shared));
let listing = f.run("misbehave", "read-dir", Some(shared.to_str().unwrap())).unwrap().unwrap();
let lines: Vec<&str> = listing.lines().collect();
assert_eq!(lines, vec!["file alpha.txt", "file beta.txt", "dir zeta"], "{listing}");
// A file is not a directory, and a path outside the grant is refused.
let file = shared.join("alpha.txt");
let err = f.run("misbehave", "read-dir", Some(file.to_str().unwrap())).unwrap_err();
assert!(err.contains("not a directory"), "{err}");
let err = f.run("misbehave", "read-dir", Some(f._tmp.path().to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "{err}");
}
/// `project.write` creates and replaces files under the project root, and never
/// leaves it, through the real component.
#[test]
fn project_write_stays_inside_the_project() {
let f = fixture(Limits::default());
f.install(&misbehave_with(f._tmp.path(), "misbehave-project-writer", "project.write"));
// A Run configuration: neither the file nor `.openidea/` exists yet.
assert_eq!(f.run("misbehave", "projwrite", Some(".openidea/run.toml")).unwrap(), None);
let conf = f.project.join(".openidea").join("run.toml");
assert_eq!(std::fs::read_to_string(&conf).unwrap(), "written inside the project\n");
// What it wrote reads back through the project.
assert_eq!(
f.run("misbehave", "read", Some(".openidea/run.toml")).unwrap().as_deref(),
Some("written inside the project\n")
);
// An absolute path inside the root lands in the same place.
let fresh = f.project.join("build").join("patch.txt");
assert_eq!(f.run("misbehave", "projwrite", Some(fresh.to_str().unwrap())).unwrap(), None);
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written inside the project\n");
// Replacing an existing file rewrites it whole.
assert_eq!(f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap(), None);
assert_eq!(std::fs::read_to_string(f.project.join("src/notes.txt")).unwrap(), "written inside the project\n");
// Unsaved changes in an editor are not overwritten.
f.workspace.open("src/notes.txt", &[]);
f.workspace.type_text("src/notes.txt", "typed ");
let err = f.run("misbehave", "projwrite", Some("src/notes.txt")).unwrap_err();
assert!(err.contains("unsaved changes"), "{err}");
// Leaving the root is refused, and nothing is written.
let err = f.run("misbehave", "projwrite", Some("../escape.txt")).unwrap_err();
assert!(err.contains("outside the project root"), "{err}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
/// `fs.write` replaces files the grant covers and nothing else, through the
/// real component.
#[test]
fn fs_write_reaches_only_the_granted_paths() {
let f = fixture(Limits::default());
let shared = f._tmp.path().join("shared");
std::fs::create_dir_all(&shared).unwrap();
let notes = shared.join("notes.txt");
std::fs::write(&notes, "old\n").unwrap();
let bommed = shared.join("bom.txt");
std::fs::write(&bommed, b"\xef\xbb\xbfold").unwrap();
let sibling = f._tmp.path().join("shared-other");
std::fs::create_dir_all(&sibling).unwrap();
let other = sibling.join("notes.txt");
std::fs::write(&other, "not the same directory\n").unwrap();
let secret = f._tmp.path().join("secret.txt");
std::fs::write(&secret, "keep me\n").unwrap();
let grant = format!("fs.write:{}", shared.to_string_lossy().replace('\\', "/"));
f.install(&misbehave_with(f._tmp.path(), "misbehave-writable", &grant));
// An existing file is replaced…
assert_eq!(f.run("misbehave", "fswrite", Some(notes.to_str().unwrap())).unwrap(), None);
assert_eq!(std::fs::read_to_string(&notes).unwrap(), "written by the plugin\n");
// …a new one is created…
let fresh = shared.join("fresh.txt");
assert_eq!(f.run("misbehave", "fswrite", Some(fresh.to_str().unwrap())).unwrap(), None);
assert_eq!(std::fs::read_to_string(&fresh).unwrap(), "written by the plugin\n");
// …and a file's encoding and BOM survive the write.
assert_eq!(f.run("misbehave", "fswrite", Some(bommed.to_str().unwrap())).unwrap(), None);
let bytes = std::fs::read(&bommed).unwrap();
assert!(bytes.starts_with(b"\xef\xbb\xbf"), "the BOM stays: {bytes:?}");
assert_eq!(String::from_utf8(bytes[3..].to_vec()).unwrap(), "written by the plugin\n");
// Outside the grant nothing changes.
let err = f.run("misbehave", "fswrite", Some(secret.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.write:"), "the refusal names what to grant: {err}");
assert_eq!(std::fs::read_to_string(&secret).unwrap(), "keep me\n");
let err = f.run("misbehave", "fswrite", Some(other.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.write:"), "a name prefix grants nothing: {err}");
let err = f.run("misbehave", "fswrite", Some("notes.txt")).unwrap_err();
assert!(err.contains("absolute"), "{err}");
let missing_parent = shared.join("conf").join("web.xml");
let err = f.run("misbehave", "fswrite", Some(missing_parent.to_str().unwrap())).unwrap_err();
assert!(err.contains("directory does not exist"), "{err}");
// Writing a directory fails rather than replacing it.
assert!(f.run("misbehave", "fswrite", Some(shared.to_str().unwrap())).is_err(), "a directory is not a file");
assert!(shared.is_dir(), "the directory is still there");
// `fs.write` does not carry `fs.read`.
let err = f.run("misbehave", "fsread", Some(notes.to_str().unwrap())).unwrap_err();
assert!(err.contains("fs.read:"), "write does not imply read: {err}");
// A refusal is the plugin's to handle, like any other error it gets.
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
/// A program kept running behind a handle: the plugin writes its stdin, closes
/// it, and waits for the exit status and what the program printed. A one-off run
/// passes the environment it names.
#[test]
fn a_plugin_keeps_a_process_running_behind_a_handle() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
f.host.set_capability("misbehave", "process:git", false).unwrap();
assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap_err().contains("process:git"));
for command in ["envrun", "envstart", "child"] {
let err = f.run("misbehave", command, None).unwrap_err();
assert!(err.contains("process:git"), "{command}: {err}");
}
// The fixture declares `process:git` as optional: it needs a program that
// reads stdin and reports its environment. Machines running these tests have
// git; without one, there is nothing to run and nothing to check.
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
let out = f.run("misbehave", "child", None).expect("the handle works").expect("a result");
let (hash, status) = out.split_once(' ').unwrap_or_else(|| panic!("{out}"));
assert_eq!(hash.len(), 40, "a sha1 of what was written: {out}");
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "{out}");
assert_eq!(status, "Some(0)", "it ended by itself: {out}");
assert!(f.run("misbehave", "envrun", Some("legacy")).unwrap().unwrap().starts_with("git version"));
let ident = f.run("misbehave", "envrun", None).expect("a run with extra environment").expect("a result");
assert_eq!(ident.trim(), "added by the plugin");
assert_eq!(f.run("misbehave", "envstart", None).unwrap().unwrap().trim(), "added by the plugin");
for command in ["envdeny-run", "envdeny-start"] {
let inherited = std::env::vars().find(|(key, _)| key != "PATH" && !key.contains('=')).unwrap().0;
for key in ["PATH", inherited.as_str(), "", "BAD=KEY", "BAD\0KEY"] {
let err = f.run("misbehave", command, Some(key)).unwrap_err();
assert!(err.contains("environment variable"), "{command} {key:?}: {err}");
}
}
// Dropping a resource returns the slot; the limit is simultaneous handles.
for _ in 0..10 {
f.run("misbehave", "childkeep", None).unwrap();
assert_eq!(f.run("misbehave", "childpoll", None).unwrap().as_deref(), Some("None"));
f.run("misbehave", "childdrop", None).unwrap();
}
for _ in 0..8 {
f.run("misbehave", "childkeep", None).unwrap();
}
assert!(f.run("misbehave", "childkeep", None).unwrap_err().contains("at most 8"));
f.run("misbehave", "childdrop", None).unwrap();
f.run("misbehave", "childkeep", None).unwrap();
f.host.forget_client("main");
assert!(f.run("misbehave", "childpoll", None).unwrap_err().contains("no process"));
assert!(f.run("misbehave", "childkeep", None).is_err(), "late starts are cancelled");
f.host.reopen_client("main");
f.run("misbehave", "childdrop", None).unwrap();
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[cfg(unix)]
#[test]
fn plugin_processes_end_with_their_owner() {
for mode in ["disable", "unload", "shutdown", "reclaim", "trap", "window"] {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
f.install(&misbehave_pkg());
f.host.set_capability("misbehave", "process:git", true).unwrap();
let marker = format!("--path={}/process-{mode}", f.project.display());
let alive = || {
let ps = std::process::Command::new("ps").args(["-axo", "command="]).output().unwrap();
assert!(ps.status.success());
String::from_utf8_lossy(&ps.stdout).lines().any(|line| line.contains(&marker))
};
f.run("misbehave", "childkeep", marker.strip_prefix("--path=")).unwrap();
assert!(alive(), "{mode}: the process started");
match mode {
"disable" => f.host.set_enabled("misbehave", false).unwrap(),
"unload" => f.host.uninstall("misbehave").unwrap(),
"shutdown" => f.host.shutdown(),
"reclaim" => {
std::thread::sleep(Duration::from_millis(75));
f.host.tick();
assert_eq!(f.host.instances(), 0);
}
"trap" => {
assert!(f.run("misbehave", "panic", None).is_err());
}
"window" => f.host.forget_client("main"),
_ => unreachable!(),
}
let deadline = Instant::now() + Duration::from_secs(5);
while alive() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert!(!alive(), "{mode}: the process must end with its owner");
}
}
/// A task that keeps reporting progress is still working: each report renews
/// the background budget, so the call outlives the 400 ms it began with. A task
/// that goes quiet is still cut off (see `tasks_that_stop_reporting_are_cut_off`).
#[test]
fn a_reporting_task_outlives_its_background_budget() {
let f = fixture(Limits {
timeout: Duration::from_millis(1_000),
background: Duration::from_millis(2_000),
..Limits::default()
});
f.install(&misbehave_pkg());
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Twenty-five waits of 100 ms: 2.5 s of wall time, more than the
// budget, spent inside the host rather than the plugin.
let started = std::time::Instant::now();
let out = f.run("misbehave", "patient", None).expect("a patient task").expect("a result");
let took = started.elapsed();
assert_eq!(out, "still here");
assert!(took >= Duration::from_millis(2_500), "it really ran the waits: {took:?}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[test]
fn private_directory_is_owned_persistent_and_removed_on_uninstall() {
let f = fixture(Limits {
timeout: Duration::from_secs(30),
idle_timeout: Some(Duration::from_millis(100)),
..Limits::default()
});
let pkg = sample("private-dir");
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
let run = |cmd: &str, arg: Option<&str>| f.host.run_command("test.private-dir", cmd, arg, Invocation::default());
let dir = PathBuf::from(run("directory", None).unwrap().unwrap());
assert!(!dir.starts_with(f.installed("test.private-dir")));
assert_eq!(run("roundtrip", None).unwrap().as_deref(), Some("fake private data\n"));
assert_eq!(run("large-cache", None).unwrap().as_deref(), Some("83886080"));
let file = dir.join("fake-secret.txt");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700);
for name in ["fake-secret.txt", "cache.bin"] {
assert_eq!(std::fs::metadata(dir.join(name)).unwrap().permissions().mode() & 0o777, 0o600);
}
}
for path in [f._tmp.path().join("secret.txt"), dir.parent().unwrap().join("adjacent.txt")] {
std::fs::write(&path, "other content").unwrap();
for cmd in ["read", "write"] {
assert_eq!(run(cmd, path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
assert_eq!(std::fs::read_to_string(&path).unwrap(), "other content");
}
let other_pkg = f._tmp.path().join("other-plugin");
copy_dir(&pkg, &other_pkg);
let manifest = other_pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap().replace("test.private-dir", "test.private-dir-other");
std::fs::write(manifest, text).unwrap();
f.host.install_from_dir(&other_pkg, &Consent::default()).unwrap();
f.host.run_command("test.private-dir-other", "roundtrip", None, Invocation::default()).unwrap();
let other_dir = PathBuf::from(
f.host.run_command("test.private-dir-other", "directory", None, Invocation::default()).unwrap().unwrap(),
);
for cmd in ["read", "write"] {
assert_eq!(run(cmd, other_dir.join("fake-secret.txt").to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
for path in [
dir.join("../adjacent.txt"),
dir.join("../../test.private-dir-other/files/fake-secret.txt"),
dir.join("../../test.private-dir-other/files/new.txt"),
] {
assert_eq!(run("write", path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
if path.exists() {
assert_eq!(run("read", path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
}
assert!(!other_dir.join("new.txt").exists());
assert_eq!(std::fs::read_to_string(dir.parent().unwrap().join("adjacent.txt")).unwrap(), "other content");
assert_eq!(std::fs::read_to_string(other_dir.join("fake-secret.txt")).unwrap(), "fake private data\n");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(other_dir.join("fake-secret.txt"), dir.join("escape.txt")).unwrap();
symlink(&other_dir, dir.join("escape-dir")).unwrap();
for path in [dir.join("escape.txt"), dir.join("escape-dir/fake-secret.txt")] {
for cmd in ["read", "write"] {
assert_eq!(run(cmd, path.to_str()).unwrap_err().kind, ErrorKind::PermissionDenied);
}
}
assert_eq!(
run("write", dir.join("escape-dir/new.txt").to_str()).unwrap_err().kind,
ErrorKind::PermissionDenied
);
}
// The same real file opens and saves through Documents, preserving privacy.
f.workspace.documents.open(&file).unwrap();
let doc = f.workspace.documents.by_path(&file).unwrap();
f.workspace
.documents
.apply_edits(doc, &[ide_core::TextEdit { range: ide_core::Range::default(), new_text: "edited ".into() }])
.unwrap();
assert!(run("write", None).unwrap_err().message.contains("unsaved changes"));
assert_eq!(run("read", None).unwrap().as_deref(), Some("edited fake private data\n"));
f.workspace.documents.save(doc).unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "edited fake private data\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
}
// Reload and idle reclaim never remove content.
f.host.reload("test.private-dir").unwrap();
assert_eq!(run("directory", None).unwrap().as_deref(), dir.to_str());
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.status("test.private-dir"), PluginStatus::Inactive);
assert!(file.exists());
f.host.uninstall("test.private-dir").unwrap();
assert!(!dir.exists());
assert!(other_dir.join("fake-secret.txt").exists());
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
assert!(run("read", None).unwrap_err().message.contains("no such file"));
}
#[test]
fn private_directory_is_available_to_providers_without_a_project() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
f.host.set_workspace(None);
f.host.run_command("test.private-dir", "roundtrip", None, Invocation::default()).unwrap();
let doc = ide_plugin::ProviderDocument {
path: f._tmp.path().join("outside.txt"),
language: "private-fixture".into(),
version: 1,
text: String::new(),
};
assert!(eventually(|| f
.host
.provide_completion(&doc, ide_core::Pos::new(0, 0), None)
.iter()
.any(|(_, item)| item.label == "fake private data\n")));
}
#[test]
fn private_directory_survives_host_restart_and_inactive_uninstall() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
f.run("test.private-dir", "roundtrip", None).unwrap();
let dir = PathBuf::from(f.run("test.private-dir", "directory", None).unwrap().unwrap());
f.host.shutdown();
let host =
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap();
host.load_all();
host.startup_finished();
assert_eq!(
host.run_command("test.private-dir", "read", None, Invocation::default()).unwrap().as_deref(),
Some("fake private data\n")
);
host.shutdown();
host.uninstall("test.private-dir").unwrap();
assert!(!dir.exists());
}
#[test]
fn private_uninstall_cleanup_failure_is_retryable() {
for fail_data in [true, false] {
let f = fixture(Limits::default());
let id = "test.private-dir";
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
f.run(id, "roundtrip", None).unwrap();
let dir = PathBuf::from(f.run(id, "directory", None).unwrap().unwrap());
let target = if fail_data { dir.parent().unwrap().to_path_buf() } else { f.plugins_dir.join(id) };
let backup = target.with_extension("blocked-backup");
// A regular file deterministically makes remove_dir_all fail, even as root.
std::fs::rename(&target, &backup).unwrap();
std::fs::write(&target, "controlled cleanup failure").unwrap();
assert!(f.host.uninstall(id).is_err());
assert!(f.host.info_of(id).is_ok(), "failed uninstall must retain its target");
assert_eq!(f.status(id), PluginStatus::Failed);
assert!(f.command_titles(id).is_empty());
std::fs::remove_file(&target).unwrap();
std::fs::rename(&backup, &target).unwrap();
if fail_data {
assert!(f.installed(id).join("plugin.toml").exists());
f.host.shutdown();
let host = PluginHost::new(
f.plugins_dir.clone(),
Arc::new(|_: Option<&str>, _: PluginEvent| {}),
Limits::default(),
)
.unwrap();
host.load_all();
host.uninstall(id).unwrap();
} else {
f.host.uninstall(id).unwrap();
}
assert!(!dir.exists());
assert!(!f.plugins_dir.join(id).exists());
}
}
#[test]
fn private_uninstall_recovers_after_package_metadata_is_removed() {
for remove_entire_package in [false, true] {
let f = fixture(Limits::default());
let id = "test.private-dir";
f.install(&sample("private-dir"));
f.run(id, "roundtrip", None).unwrap();
let package = f.installed(id);
let root = f.plugins_dir.join(id);
let backup = root.with_extension("blocked-backup");
std::fs::rename(&root, &backup).unwrap();
std::fs::write(&root, "controlled package cleanup failure").unwrap();
assert!(f.host.uninstall(id).is_err());
std::fs::remove_file(&root).unwrap();
std::fs::rename(&backup, &root).unwrap();
// Reproduce the metadata loss a partial recursive deletion can leave behind.
if remove_entire_package {
std::fs::remove_dir_all(&root).unwrap();
} else {
std::fs::remove_file(root.join("active")).unwrap();
std::fs::remove_file(package.join("plugin.toml")).unwrap();
}
if remove_entire_package {
// An interrupted cleanup (and older records) has no saved failure yet.
let record = f.plugins_dir.join(".uninstall").join(format!("{id}.json"));
let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&record).unwrap()).unwrap();
value.as_object_mut().unwrap().remove("error");
std::fs::write(record, serde_json::to_vec(&value).unwrap()).unwrap();
}
f.host.shutdown();
let host =
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap();
host.load_all();
assert!(host.info_of(id).is_ok(), "partial package cleanup must retain a restartable uninstall target");
assert!(host.info_of(id).unwrap().error.is_some());
assert_eq!(host.info_of(id).unwrap().status, PluginStatus::Failed);
assert!(host.reload(id).is_err());
assert!(host.set_enabled(id, true).is_err());
assert_eq!(
host.install_from_dir(&sample("private-dir"), &Consent::all()).unwrap_err().kind,
ErrorKind::Unavailable
);
host.uninstall(id).unwrap();
assert!(host.info_of(id).is_err());
assert!(!root.exists());
assert!(!f.plugins_dir.join(".uninstall").join(format!("{id}.json")).exists());
host.install_from_dir(&sample("private-dir"), &Consent::all()).unwrap();
host.uninstall(id).unwrap();
assert!(!root.exists());
}
}
#[test]
fn private_uninstall_cleans_up_a_package_with_a_damaged_manifest() {
let f = fixture(Limits::default());
let id = "test.private-dir";
f.install(&sample("private-dir"));
f.run(id, "roundtrip", None).unwrap();
let dir = PathBuf::from(f.run(id, "directory", None).unwrap().unwrap());
let package = f.installed(id);
f.host.shutdown();
std::fs::write(package.join("plugin.toml"), "broken = [").unwrap();
let host =
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap();
host.load_all();
assert_eq!(host.info_of(id).unwrap().status, PluginStatus::Failed);
host.uninstall(id).unwrap();
assert!(!dir.exists());
assert!(!f.plugins_dir.join(id).exists());
let broken = f.plugins_dir.join("Broken Package");
std::fs::create_dir(&broken).unwrap();
std::fs::write(broken.join("plugin.toml"), "broken = [").unwrap();
let other = f.plugins_dir.join("invalid-broken-package");
std::fs::create_dir(&other).unwrap();
std::fs::write(other.join("keep.txt"), "untouched").unwrap();
host.load_all();
host.uninstall("Broken Package").unwrap();
assert!(!broken.exists());
assert_eq!(std::fs::read_to_string(other.join("keep.txt")).unwrap(), "untouched");
}
#[test]
fn private_uninstall_cannot_be_restarted_through_lifecycle_actions() {
let f = fixture(Limits::default());
let id = "test.private-dir";
f.install(&sample("private-dir"));
f.run(id, "roundtrip", None).unwrap();
let dir = PathBuf::from(f.run(id, "directory", None).unwrap().unwrap());
let target = dir.parent().unwrap();
let backup = target.with_extension("blocked-backup");
std::fs::rename(target, &backup).unwrap();
std::fs::write(target, "controlled cleanup failure").unwrap();
assert!(f.host.uninstall(id).is_err());
let error = f.host.info_of(id).unwrap().error.unwrap();
for restart in [false, true] {
let recovered;
let host = if restart {
f.host.shutdown();
recovered = PluginHost::new(
f.plugins_dir.clone(),
Arc::new(|_: Option<&str>, _: PluginEvent| {}),
Limits::default(),
)
.unwrap();
recovered.load_all();
&recovered
} else {
&f.host
};
assert_eq!(host.info_of(id).unwrap().error.as_deref(), Some(error.as_str()));
assert!(host.set_enabled(id, true).is_err());
assert!(host.set_enabled(id, false).is_err());
assert!(host.reload(id).is_err());
assert!(host.grant(id, &Consent::default()).is_err());
assert!(host.set_capability(id, "project.read", true).is_err());
assert!(host.install_from_dir(&sample("private-dir"), &Consent::default()).is_err());
assert!(host.roll_back_update(id).is_err());
host.startup_finished();
assert_eq!(host.info_of(id).unwrap().status, PluginStatus::Failed);
assert!(host.run_command(id, "roundtrip", None, Invocation::default()).is_err());
if restart {
std::fs::remove_file(target).unwrap();
std::fs::rename(&backup, target).unwrap();
host.uninstall(id).unwrap();
assert!(!dir.exists());
}
}
}
#[test]
fn private_uninstall_retains_failed_secret_cleanup_for_retry() {
struct FailingSecrets;
impl SecretStore for FailingSecrets {
fn get(&self, _: &str, _: &str) -> Option<String> {
None
}
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
Err("keychain locked".into())
}
}
let f = fixture(Limits::default());
let id = "test.private-dir";
let pkg = f._tmp.path().join("private-with-secret");
copy_dir(&sample("private-dir"), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap().replace("required = []", "required = [\"secrets\"]");
std::fs::write(
manifest,
format!("{text}\n[[contributes.secrets]]\nname = \"cleanup-test\"\nlabel = \"Cleanup test\"\n"),
)
.unwrap();
f.install(&pkg);
f.host.set_secret_store(Arc::new(FailingSecrets));
assert!(f.host.uninstall(id).is_err());
assert!(f.host.info_of(id).unwrap().error.unwrap().contains("keychain locked"));
assert!(f.installed(id).join("plugin.toml").exists());
f.host.shutdown();
let host =
PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
.unwrap();
host.load_all();
assert!(host.info_of(id).unwrap().error.unwrap().contains("keychain locked"));
host.uninstall(id).unwrap();
assert!(host.info_of(id).is_err());
}
#[cfg(unix)]
#[test]
fn private_uninstall_does_not_follow_cleanup_symlinks() {
use std::os::unix::fs::symlink;
for relative in [".data", ".data/test.private-dir", "test.private-dir"] {
let f = fixture(Limits::default());
let id = "test.private-dir";
f.install(&sample("private-dir"));
f.run(id, "roundtrip", None).unwrap();
let target = f.plugins_dir.join(relative);
let backup = target.with_extension("blocked-backup");
std::fs::rename(&target, &backup).unwrap();
let outside = f._tmp.path().join("outside");
copy_dir(&backup, &outside);
std::fs::write(outside.join("keep.txt"), "untouched").unwrap();
symlink(&outside, &target).unwrap();
assert!(f.host.uninstall(id).is_err(), "{relative}");
assert_eq!(std::fs::read_to_string(outside.join("keep.txt")).unwrap(), "untouched");
std::fs::remove_file(&target).unwrap();
std::fs::rename(&backup, &target).unwrap();
f.host.uninstall(id).unwrap();
assert!(f.host.info_of(id).is_err());
assert_eq!(std::fs::read_to_string(outside.join("keep.txt")).unwrap(), "untouched");
}
let f = fixture(Limits::default());
let id = "test.private-dir";
f.install(&sample("private-dir"));
let outside = f._tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::fs::write(outside.join("keep.txt"), "untouched").unwrap();
let nested = f.plugins_dir.join(".data").join(id).join("nested");
std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
symlink(&outside, nested).unwrap();
f.host.uninstall(id).unwrap();
assert_eq!(std::fs::read_to_string(outside.join("keep.txt")).unwrap(), "untouched");
for file in ["secrets.json", "secrets.tmp"] {
let f = fixture(Limits::default());
f.install(&sample("private-dir"));
let outside = f._tmp.path().join("outside-secret");
std::fs::write(&outside, "untouched").unwrap();
let target = f.plugins_dir.join(".data").join(id).join(file);
std::fs::create_dir_all(target.parent().unwrap()).unwrap();
symlink(&outside, &target).unwrap();
assert!(f.host.uninstall(id).is_err());
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "untouched");
std::fs::remove_file(target).unwrap();
f.host.uninstall(id).unwrap();
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "untouched");
}
}
#[test]
fn private_uninstall_blocks_inactive_reactivation_during_cleanup() {
struct BlockingSecrets {
entered: std::sync::Barrier,
release: std::sync::Barrier,
}
impl SecretStore for BlockingSecrets {
fn get(&self, _: &str, _: &str) -> Option<String> {
None
}
fn set(&self, _: &str, _: &str, _: Option<&str>) -> Result<(), String> {
self.entered.wait();
self.release.wait();
Ok(())
}
}
let f = fixture(Limits::default());
let pkg = f._tmp.path().join("private-with-secret");
copy_dir(&sample("private-dir"), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap().replace("required = []", "required = [\"secrets\"]");
std::fs::write(
manifest,
format!("{text}\n[[contributes.secrets]]\nname = \"cleanup-test\"\nlabel = \"Cleanup test\"\n"),
)
.unwrap();
f.install(&pkg);
assert_eq!(f.status("test.private-dir"), PluginStatus::Inactive);
let store = Arc::new(BlockingSecrets { entered: std::sync::Barrier::new(2), release: std::sync::Barrier::new(2) });
f.host.set_secret_store(store.clone());
let result = std::thread::scope(|scope| {
let uninstall = scope.spawn(|| f.host.uninstall("test.private-dir"));
store.entered.wait();
let result = f.run("test.private-dir", "roundtrip", None);
store.release.wait();
uninstall.join().unwrap().unwrap();
result
});
assert!(result.is_err(), "plugin must remain stopped during uninstall");
}
#[cfg(unix)]
#[test]
fn private_paths_work_with_open_location_and_approved_process_arguments() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("private-dir"), &Consent::default()).unwrap();
f.run("test.private-dir", "roundtrip", None).unwrap();
let path = PathBuf::from(f.run("test.private-dir", "open", None).unwrap().unwrap());
assert!(f.take_events().iter().any(|(client, event)| client.as_deref() == Some("main")
&& matches!(event, PluginEvent::OpenLocation { path: opened, line: 0, column: 0, .. } if opened.0 == path)));
assert!(f.run("test.private-dir", "process", None).unwrap_err().contains("process:cat"));
f.host.set_capability("test.private-dir", "process:cat", true).unwrap();
assert_eq!(f.run("test.private-dir", "process", None).unwrap().as_deref(), Some("fake private data\n"));
}
/// A missing capability is an error the plugin can handle, not a trap.
#[test]
fn ungranted_capabilities_are_denied_not_trapped() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
let editor = f.workspace.open("src/notes.txt", &[]);
let inv = Invocation { editor: Some(editor.clone()), ..f.inv() };
let err = f.host.run_command("misbehave", "insert", None, inv).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
assert!(err.message.contains("editor.write"), "{err:?}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.crashes), (PluginStatus::Active, 0), "the instance lives on");
assert_eq!(info.stats.errors, 0, "a missing grant is not the plugin failing");
let err = f.host.run_command("misbehave", "cancel", None, f.inv()).unwrap_err();
assert_eq!(err.kind, ErrorKind::Cancelled, "{err:?}");
assert_eq!(f.host.info_of("misbehave").unwrap().stats.errors, 0, "nor is the user cancelling");
assert_eq!(f.workspace.text("src/notes.txt"), "one two three\nfour five\n");
}
/// A trap drops the instance, which restarts on next use after a
/// backoff that doubles; only consecutive crashes stop the plugin.
#[test]
fn crashes_restart_with_backoff_until_the_circuit_opens() {
let backoff = Duration::from_millis(150);
let f = fixture(Limits { restart_backoff: backoff, max_failures: 3, ..Limits::default() });
f.install(&sample("hello"));
f.install(&misbehave_pkg());
f.take_events();
let err = f.run("misbehave", "panic", None).unwrap_err();
assert!(err.contains("crashed"), "{err}");
assert!(err.contains("boom"), "stderr panic message is surfaced: {err}");
let info = f.host.info_of("misbehave").unwrap();
assert_eq!(info.status, PluginStatus::Inactive);
assert!(info.error.as_deref().is_some_and(|e| e.contains("boom")));
assert!(!info.contributions.is_empty(), "contributions stay while it can restart");
let events = f.take_events();
assert!(events.iter().any(|(client, e)| client.as_deref() == Some("main")
&& matches!(e, PluginEvent::Notification { level: NotificationLevel::Warning, title, .. } if title.contains("crashed"))));
// Backing off, then a fresh instance; a call that returns resets the count.
assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("restarts in"));
std::thread::sleep(backoff + Duration::from_millis(50));
assert_eq!(f.run("misbehave", "echo", Some("back")).unwrap().as_deref(), Some("back"));
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.activations, info.error), (PluginStatus::Active, 2, None));
// Three in a row: stopped for the session, backing off between them (the wait
// doubles: `Limits::backoff`, tested where it is defined; wall-clock waits on CI
// are too noisy to compare).
f.take_events();
for crash in 0..3 {
let err = loop {
match f.run("misbehave", "panic", None) {
Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)),
other => break other.unwrap_err(),
}
};
assert!(err.contains("crashed"), "{err}");
if crash < 2 {
assert!(f.run("misbehave", "echo", None).unwrap_err().contains("restarts in"), "backing off");
}
}
let info = f.host.info_of("misbehave").unwrap();
assert_eq!(info.status, PluginStatus::Failed);
assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error);
assert!(info.enabled, "failure is per session, not persisted");
assert!(info.contributions.is_empty(), "a failed plugin's contributions are withdrawn");
let events = f.take_events();
assert!(events.iter().any(|(_, e)| matches!(e,
PluginEvent::Notification { level: NotificationLevel::Error, title, .. } if title.contains("was disabled"))));
assert_eq!(
events
.iter()
.filter(|(_, e)| matches!(e, PluginEvent::Notification { level: NotificationLevel::Warning, .. }))
.count(),
1,
"one crash notice per streak"
);
assert!(f.run("misbehave", "echo", Some("x")).unwrap_err().contains("not running"));
// Other plugins are unaffected.
let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0)]);
assert!(f.run_in(&editor, "hello", "insert-date").is_ok());
// Toggling restarts it with a fresh instance.
f.host.set_enabled("misbehave", true).unwrap();
assert_eq!(f.run("misbehave", "echo", Some("again")).unwrap().as_deref(), Some("again"));
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
#[test]
fn fuel_exhaustion_traps() {
let f =
fixture(Limits { fuel: 50_000_000, timeout: Duration::from_secs(30), max_failures: 1, ..Limits::default() });
f.install(&misbehave_pkg());
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("CPU budget"), "{err}");
assert_eq!(f.status("misbehave"), PluginStatus::Failed, "max_failures 1: the first crash stops it");
}
#[test]
fn wall_clock_timeout_traps() {
let f = fixture(Limits { fuel: u64::MAX, timeout: Duration::from_millis(200), ..Limits::default() });
f.install(&misbehave_pkg());
let started = std::time::Instant::now();
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("timed out after 200ms"), "{err}");
assert!(started.elapsed() < Duration::from_secs(5));
assert_eq!(f.host.info_of("misbehave").unwrap().stats.crashes, 1);
}
#[test]
fn memory_limit_traps() {
let f = fixture(Limits { memory_bytes: 32 << 20, ..Limits::default() });
f.install(&misbehave_pkg());
let err = f.run("misbehave", "alloc", None).unwrap_err();
assert!(err.contains("memory"), "{err}");
assert_eq!(f.host.info_of("misbehave").unwrap().stats.crashes, 1);
}
/// A running call can be interrupted from another thread; the
/// instance is rebuilt on next use without counting as a crash.
#[test]
fn running_calls_can_be_cancelled() {
let f = Arc::new(fixture(Limits { fuel: u64::MAX, timeout: Duration::from_secs(30), ..Limits::default() }));
f.install(&misbehave_pkg());
let spinning = {
let f = f.clone();
std::thread::spawn(move || f.run("misbehave", "spin", None))
};
// A cancel before the call runs (the instance may still be starting) does nothing: repeat it.
let until = Instant::now() + Duration::from_secs(5);
while !spinning.is_finished() {
assert!(Instant::now() < until, "the running call wasn't interrupted");
f.host.cancel("misbehave");
std::thread::sleep(Duration::from_millis(20));
}
let err = spinning.join().unwrap().unwrap_err();
assert!(err.contains("was cancelled"), "{err}");
assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x"), "no backoff");
let stats = f.host.info_of("misbehave").unwrap().stats;
assert_eq!((stats.crashes, stats.activations), (0, 2));
}
/// `misbehave` contributing a language, so its `syntax-provider` serves it.
fn misbehave_with_language(dir: &Path) -> PathBuf {
let pkg = dir.join("misbehave");
copy_dir(&misbehave_pkg(), &pkg);
std::fs::write(
pkg.join("plugin.toml"),
"id = \"misbehave\"\nname = \"Misbehave\"\nversion = \"0.0.1\"\nengines.openidea = \"^1.0\"\n\
wasm = \"dist/plugin.wasm\"\nactivation = [\"on-language:mis\"]\n\
[[contributes.languages]]\npath = \"mis.toml\"\n",
)
.unwrap();
std::fs::write(pkg.join("mis.toml"), "id = \"mis\"\nname = \"Mis\"\nextensions = [\"mis\"]\n").unwrap();
pkg
}
/// Edit-path calls stop waiting at the soft deadline (the result is
/// dropped, the instance kept) and are interrupted at the hard one.
#[test]
fn edit_path_calls_have_soft_and_hard_deadlines() {
use ide_core::LanguageId;
let tmp = tempfile::tempdir().unwrap();
let limits = Limits {
edit_soft: Duration::from_millis(50),
edit_hard: Duration::from_millis(400),
fuel: u64::MAX,
restart_backoff: Duration::ZERO,
..Limits::default()
};
let host =
Arc::new(PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
host.load_all();
host.startup_finished();
host.install_from_dir(&misbehave_with_language(tmp.path()), &Consent::all()).unwrap();
let provider = host.syntax_provider();
let mis = LanguageId::new("mis");
// The first call starts the instance, which may take longer than the soft deadline.
let mut tokens = None;
assert!(eventually(|| {
tokens = provider.highlights(&mis, "fast");
tokens.is_some()
}));
let tokens = tokens.unwrap();
assert_eq!((tokens[0].start, tokens[0].end, tokens[0].kind.as_str()), (0, 4, "keyword"));
// Slower than the soft deadline: dropped (however late this thread gets
// to look), but the instance lives on.
assert!(provider.highlights(&mis, "sleep:150").is_none());
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
let info = host.info_of("misbehave").unwrap();
assert!(info.stats.slow_calls >= 1, "{:?}", info.stats);
assert_eq!((info.stats.crashes, info.stats.activations), (0, 1));
// Past the hard deadline: interrupted; the next call gets a new instance.
assert!(provider.highlights(&mis, "spin").is_none());
std::thread::sleep(Duration::from_millis(450));
host.tick();
let info = host.info_of("misbehave").unwrap();
assert_eq!(info.stats.crashes, 1, "{:?}", info.error);
assert!(info.error.as_deref().is_some_and(|e| e.contains("timed out after 400ms")), "{:?}", info.error);
// The new instance may take longer to start than the soft deadline on a slow runner.
assert!(eventually(|| provider.highlights(&mis, "fast").is_some()));
assert_eq!(host.info_of("misbehave").unwrap().stats.activations, 2);
}
/// Editor providers of several plugins are asked at the same time: the wait
/// is the slowest plugin's, not the sum, so each has the whole budget.
#[test]
fn providers_of_several_plugins_run_concurrently() {
let tmp = tempfile::tempdir().unwrap();
// A budget no runner misses, so only the overlap is measured.
let limits = Limits {
edit_soft: Duration::from_secs(5),
edit_hard: Duration::from_secs(10),
fuel: u64::MAX,
..Limits::default()
};
let host =
Arc::new(PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
host.load_all();
host.startup_finished();
for id in ["slow-a", "slow-b"] {
let pkg = tmp.path().join(id);
copy_dir(&misbehave_pkg(), &pkg);
std::fs::write(
pkg.join("plugin.toml"),
format!(
"id = \"{id}\"\nname = \"{id}\"\nversion = \"0.0.1\"\nengines.openidea = \"^1.0\"\n\
wasm = \"dist/plugin.wasm\"\nactivation = [\"on-language:text\"]\n\
[[contributes.providers]]\nkinds = [\"completion\"]\nlanguages = [\"text\"]\n"
),
)
.unwrap();
host.install_from_dir(&pkg, &Consent::all()).unwrap();
}
let doc = |text: &str| ide_plugin::ProviderDocument {
path: "/p/a.txt".into(),
language: "text".into(),
version: 1,
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
// Activation is not what is measured: a first call that starts an instance
// can run past the edit-path budget, and its answer is dropped.
assert!(eventually(|| host.provide_completion(&doc("fast"), at, None).len() == 2));
let started = Instant::now();
let items = host.provide_completion(&doc("sleep:500"), at, None);
let elapsed = started.elapsed();
let mut from: Vec<&str> = items.iter().map(|(p, _)| p.as_str()).collect();
from.sort();
assert_eq!(from, ["slow-a", "slow-b"], "both within the budget");
assert!(elapsed < Duration::from_millis(950), "one after the other would take 1000ms: {elapsed:?}");
assert_eq!(host.info_of("slow-b").unwrap().stats.slow_calls, 0);
}
/// Idle instances are reclaimed; the plugin activates again on its
/// next use, and `on-schedule` activations don't keep it alive.
#[test]
fn idle_instances_are_reclaimed_and_schedules_reactivate() {
let tmp = tempfile::tempdir().unwrap();
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
f.install(&misbehave_pkg());
assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x"));
assert_eq!(f.host.instances(), 1);
f.host.tick();
assert_eq!(f.host.instances(), 1, "not idle yet");
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.host.instances(), 0);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!((info.status, info.stats.reclaims), (PluginStatus::Inactive, 1));
assert_eq!(f.command_titles("misbehave").len(), 24, "contributions stay");
assert_eq!(f.run("misbehave", "echo", Some("y")).unwrap().as_deref(), Some("y"));
assert_eq!(f.host.info_of("misbehave").unwrap().stats.activations, 2);
let pkg = hello_variant(tmp.path(), |t| {
hello_02(t, "\"editor.read\"")
.replace("activation = [\"on-command:insert-date\"]", "activation = [\"on-schedule:60\"]")
});
f.install(&pkg);
assert_eq!(f.status("hello"), PluginStatus::Inactive);
let now = Instant::now();
f.host.tick_at(now);
f.host.tick_at(now + Duration::from_secs(59));
assert_eq!(f.status("hello"), PluginStatus::Inactive, "not due yet");
f.host.tick_at(now + Duration::from_secs(61));
assert!(f.host.wait_events(Duration::from_secs(10)));
assert_eq!(f.status("hello"), PluginStatus::Active, "due");
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.status("hello"), PluginStatus::Inactive, "reclaimed again");
}
#[test]
fn contribution_only_plugins() {
let f = fixture(Limits::default());
let theme = f.install(&theme_template());
assert_eq!(theme.status, PluginStatus::Static);
assert!(!theme.has_code);
assert!(theme.icon.is_none());
assert_eq!(f.theme_kinds("openidea.harbor-night")[0].0, "harbor-night");
let lang = f.install(&sample("language-toy"));
assert_eq!(lang.status, PluginStatus::Static);
assert_eq!(f.host.languages()[0].id.as_str(), "toy");
assert_eq!(f.host.languages()[0].block_comment, Some(("(*".into(), "*)".into())));
assert!(f.run("language-toy", "x", None).unwrap_err().contains("no code"));
f.take_events();
let active = f.host.set_theme(Some(("openidea.harbor-night", "harbor-night"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#14171c");
assert_eq!(active.theme.tokens["--syn-string"], "#9ccc7a");
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ThemeChanged { theme: Some(_) })));
assert_eq!(f.host.list(None).active_theme, Some(active));
// Disabling the plugin withdraws its theme and language.
f.host.set_enabled("openidea.harbor-night", false).unwrap();
assert!(f.host.active_theme().is_none());
assert!(f.take_events().iter().any(|(_, e)| *e == PluginEvent::ThemeChanged { theme: None }));
f.host.set_enabled("language-toy", false).unwrap();
assert!(f.host.languages().is_empty());
assert_eq!(f.status("language-toy"), PluginStatus::Disabled);
}
#[test]
fn theme_template_installs_with_its_editor_scheme() {
let f = fixture(Limits::default());
let info = f.install(&theme_template());
assert_eq!(info.status, PluginStatus::Static);
assert_eq!(f.theme_kinds("openidea.harbor-night"), [("harbor-night".to_owned(), ThemeKind::Dark)]);
// The scheme file is copied with the package and merged over the UI tokens.
assert!(f.installed("openidea.harbor-night").join("themes/harbor-night.scheme.json").is_file());
let active = f.host.set_theme(Some(("openidea.harbor-night", "harbor-night"))).unwrap().unwrap();
let tokens = &active.theme.tokens;
assert!(
tokens.contains_key("--bg-app") && tokens.contains_key("--syn-keyword") && tokens.contains_key("--ansi-red")
);
assert!(tokens.len() > 150);
}
#[test]
fn theme_packages_install_with_their_licence() {
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
write_theme_pair(dir.path());
let info = f.install(dir.path());
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let installed = f.installed("theme-pair");
assert!(std::fs::read_to_string(installed.join("LICENSE")).unwrap().contains("MIT License"));
assert!(installed.join("NOTICE").is_file());
for (theme, kind) in [("day", ThemeKind::Light), ("night", ThemeKind::Dark)] {
let active = f.host.set_theme(Some(("theme-pair", theme))).unwrap().unwrap();
assert_eq!(active.theme.kind, kind);
}
}
#[test]
fn theme_frame_follows_the_selection() {
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
write_theme_pair(dir.path());
f.install(dir.path());
// Nothing to attach it to without a selected theme.
f.host.set_theme_frame(Some("#111111")).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme(Some(("theme-pair", "night"))).unwrap();
assert!(f.host.set_theme_frame(Some("hsl(220, 13%, 15%)")).is_err());
f.host.set_theme_frame(Some("#111111")).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
f.host.set_enabled("theme-pair", false).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_enabled("theme-pair", true).unwrap();
assert_eq!(f.host.startup_frame().as_deref(), Some("#111111"));
// A new selection drops the old theme's colour until the UI reports the new one.
f.host.set_theme(Some(("theme-pair", "day"))).unwrap();
assert_eq!(f.host.startup_frame(), None);
f.host.set_theme_frame(Some("#eeeeee")).unwrap();
f.host.set_theme(None).unwrap();
assert_eq!(f.host.startup_frame(), None);
}
#[test]
fn theme_with_broken_editor_scheme_is_rejected() {
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("t")).unwrap();
std::fs::write(
dir.path().join("plugin.toml"),
"id = \"bad-scheme\"\nname = \"B\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.themes]]\npath = \"t/a.json\"\neditor-scheme = \"t/a.scheme.json\"\n",
)
.unwrap();
std::fs::write(dir.path().join("t/a.json"), r##"{"id":"a","name":"A","kind":"light","tokens":{}}"##).unwrap();
std::fs::write(dir.path().join("t/a.scheme.json"), "{\n \"tokens\": { \"--caret\": \"url(x)\" }\n}").unwrap();
// Validation happens before anything is copied.
let err = f.host.install_from_dir(dir.path(), &Consent::all()).unwrap_err().message;
assert!(err.contains("a.scheme.json"), "{err}");
assert!(!f.plugins_dir.join("bad-scheme").exists());
// Already on disk (copied by hand): listed as failed.
copy_dir(dir.path(), &f.plugins_dir.join("bad-scheme"));
f.host.load_all();
let info = f.host.info_of("bad-scheme").unwrap();
assert_eq!(info.status, PluginStatus::Failed);
assert!(info.error.unwrap_or_default().contains("a.scheme.json"));
}
#[test]
fn state_persists_across_restarts() {
let f = fixture(Limits::default());
f.install(&sample("hello"));
f.install(&theme_template());
f.host.set_enabled("hello", false).unwrap();
f.host.set_theme(Some(("openidea.harbor-night", "harbor-night"))).unwrap();
f.host.set_theme_frame(Some("#1f252e")).unwrap();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
// Known before the plugins are loaded: new windows open in the theme's frame colour.
assert_eq!(again.startup_frame().as_deref(), Some("#1f252e"));
assert!(!again.wait_initial_load(Duration::from_millis(1)));
again.load_all();
assert!(again.wait_initial_load(Duration::ZERO));
let list = again.list(None);
let hello = list.plugins.iter().find(|p| p.id == "hello").unwrap();
assert!(!hello.enabled);
assert_eq!(hello.status, PluginStatus::Disabled);
assert_eq!(list.active_theme.unwrap().theme.id, "harbor-night");
assert!(
again
.run_command("hello", "insert-date", None, Invocation::default())
.unwrap_err()
.message
.contains("disabled")
);
}
#[test]
fn startup_loads_without_instantiating_wasm() {
let f = fixture(Limits::default());
f.install(&sample("hello"));
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
let hello = again.info_of("hello").unwrap();
assert_eq!(hello.status, PluginStatus::Inactive, "no instance before start-up finished");
assert_eq!(hello.stats.activations, 0);
assert_eq!(again.ui_items(COMMANDS).len(), 2, "commands are shown without running code");
again.startup_finished();
assert_eq!(again.instances(), 0, "hello waits for its commands");
// `on-startup-finished` plugins are activated then, not before.
let tmp = tempfile::tempdir().unwrap();
let eager = hello_variant(tmp.path(), |t| t.replace("on-command:*", "on-startup-finished"));
f.install(&eager);
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.instances(), 0);
again.startup_finished();
assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Active);
}
#[test]
fn lazy_activation_on_command() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |t| hello_02(t, "\"project.read\", \"editor.read\", \"editor.write\""));
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(info.activation, ["on-command:insert-date"]);
assert_eq!(f.command_titles("hello").len(), 2);
let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 0)]);
assert!(f.run_in(&editor, "hello", "insert-date").unwrap().is_some());
assert_eq!(f.status("hello"), PluginStatus::Active);
}
#[test]
fn project_plugins_need_consent_even_in_trusted_projects() {
let f = fixture(Limits::default());
f.host.set_project_policy(Arc::new(|_| true));
let dev = f.project.join(DEV_PLUGINS_DIR).join("toy");
copy_dir(&sample("language-toy"), &dev);
f.host.sync_project(&f.project);
let mine = f.host.list(Some(&f.project));
assert_eq!(mine.plugins.len(), 1);
assert_eq!(mine.plugins[0].source, PluginSource::Dev);
assert_eq!(mine.plugins[0].status, PluginStatus::NeedsConsent);
assert!(f.host.languages().is_empty(), "nothing takes effect before consent");
assert!(f.host.list(None).plugins.is_empty(), "project plugins only show for their project");
f.host.grant("language-toy", &Consent::default()).unwrap();
assert_eq!(f.status("language-toy"), PluginStatus::Static);
assert_eq!(f.host.languages().len(), 1);
assert!(f.host.uninstall("language-toy").is_err());
assert!(f.host.install_from_dir(&sample("language-toy"), &Consent::all()).is_err(), "id clash with a dev plugin");
// Edit + reload picks up changes; the grant is by path, so no new prompt.
let manifest = dev.join("plugin.toml");
let src = std::fs::read_to_string(&manifest).unwrap().replace("Toy Language", "Toy Language (dev)");
std::fs::write(&manifest, src).unwrap();
let info = f.host.reload("language-toy").unwrap();
assert_eq!(info.name, "Toy Language (dev)");
assert_eq!(info.status, PluginStatus::Static);
std::fs::remove_dir_all(&dev).unwrap();
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
#[test]
fn untrusted_or_unconfirmed_project_plugins_never_run() {
let f = fixture(Limits::default());
// Its commands are registered by `activate` only.
let dev = f.project.join(DEV_PLUGINS_DIR).join("misbehave");
copy_dir(&misbehave_pkg(), &dev);
let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false));
let policy = trusted.clone();
f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire)));
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
assert!(f.host.info_of("misbehave").is_err());
// Trusted but not confirmed: listed, nothing runs, activation registers nothing.
trusted.store(true, std::sync::atomic::Ordering::Release);
f.host.sync_project(&f.project);
let info = f.host.info_of("misbehave").unwrap();
assert_eq!(info.status, PluginStatus::NeedsConsent);
assert!(f.command_titles("misbehave").is_empty(), "unconfirmed plugin registered its activation witness");
assert!(f.run("misbehave", "echo", None).unwrap_err().contains("permission"));
// Positive control: after consent, activation itself registers the commands.
let info = f.host.grant("misbehave", &Consent::default()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(f.run("misbehave", "echo", Some("x")).unwrap().as_deref(), Some("x"));
assert!(f.command_titles("misbehave").iter().any(|t| t == "echo"));
trusted.store(false, std::sync::atomic::Ordering::Release);
assert!(f.host.reload("misbehave").is_err());
assert!(f.host.set_enabled("misbehave", true).is_err());
assert!(f.run("misbehave", "echo", None).is_err());
let doc = ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: "prompts".into(),
};
let action = ide_plugin::CodeAction {
title: "Ask input".into(),
kind: None,
preferred: false,
edits: Vec::new(),
operations: Vec::new(),
open: None,
data: Some("input".into()),
file_action: false,
};
let error = f.host.resolve_code_action("misbehave", &doc, action, f.inv()).unwrap_err();
assert!(error.message.contains("Restricted Mode"), "{}", error.message);
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
f.host.sync_project(&f.project);
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
}
#[test]
fn new_capabilities_require_consent_again() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let v1 = hello_variant(&tmp.path().join("v1"), |t| hello_02(t, "\"project.read\""));
let info = f.install(&v1);
assert_eq!(info.status, PluginStatus::Inactive);
assert!(info.capabilities.iter().all(|c| c.granted));
// Same capabilities, new version: accepted silently.
let v2 = hello_variant(&tmp.path().join("v2"), |t| {
hello_02(t, "\"project.read\"").replace("version = \"0.3.0\"", "version = \"0.3.1\"")
});
f.host.load_all();
std::fs::remove_dir_all(f.plugins_dir.join("hello")).unwrap();
copy_dir(&v2, &f.plugins_dir.join("hello"));
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Inactive);
drop(again);
// A new required capability: waits for consent, nothing contributed.
let v3 = hello_variant(&tmp.path().join("v3"), |t| {
hello_02(t, "\"project.read\", \"editor.write\"").replace("version = \"0.3.0\"", "version = \"0.4.0\"")
});
std::fs::remove_dir_all(f.plugins_dir.join("hello")).unwrap();
copy_dir(&v3, &f.plugins_dir.join("hello"));
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
let info = again.info_of("hello").unwrap();
assert_eq!(info.status, PluginStatus::NeedsConsent);
let write = info.capabilities.iter().find(|c| c.id == "editor.write").unwrap();
assert!(!write.granted);
assert!(again.ui_items(COMMANDS).is_empty());
again.grant("hello", &Consent::default()).unwrap();
assert_eq!(again.info_of("hello").unwrap().status, PluginStatus::Inactive);
// Revoking a required capability stops it again.
let info = again.set_capability("hello", "editor.write", false).unwrap();
assert_eq!(info.status, PluginStatus::NeedsConsent);
}
#[test]
fn migrated_plugins_get_low_risk_capabilities_and_ask_on_first_use() {
let tmp = tempfile::tempdir().unwrap();
let plugins_dir = tmp.path().join("plugins");
// Installed by a version without the grant ledger (a 0.1 manifest).
copy_dir(&sample("hello"), &plugins_dir.join("hello"));
let manifest = plugins_dir.join("hello/plugin.toml");
std::fs::write(&manifest, hello_01(&std::fs::read_to_string(&manifest).unwrap())).unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
move |client: Option<&str>, e: PluginEvent| events.lock().push((client.map(str::to_owned), e))
};
let host = PluginHost::new(plugins_dir.clone(), Arc::new(sink), Limits::default()).unwrap();
host.load_all();
let info = host.info_of("hello").unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let granted: Vec<_> = info.capabilities.iter().filter(|c| c.granted).map(|c| c.id.as_str()).collect();
assert_eq!(granted, ["project.read", "editor.read"]);
let inv = Invocation { client: Some("w".into()), ..Invocation::default() };
let err = host.run_command("hello", "insert-date", None, inv.clone()).unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(events.lock().iter().any(|(c, e)| c.as_deref() == Some("w")
&& matches!(e, PluginEvent::ConsentRequested { capabilities, .. } if capabilities[0].id == "editor.write")));
host.grant("hello", &Consent::default()).unwrap();
// Runs now (and fails only for want of an editor).
let err = host.run_command("hello", "insert-date", None, inv).unwrap_err();
assert!(err.message.contains("no file is open"), "{err:?}");
}
#[cfg(unix)]
#[test]
fn replaced_binaries_are_refused() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir(&bin).unwrap();
std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone();
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
let pkg = tmp.path().join("pkg");
copy_dir(&sample("language-toy"), &pkg);
let lang = pkg.join("languages/toy.toml");
let text = std::fs::read_to_string(&lang).unwrap();
std::fs::write(&lang, format!("{text}\n[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n")).unwrap();
let preview = f.host.preview_install(&pkg).unwrap();
let lsp = preview.capabilities.iter().find(|c| c.id == "process:toylsp").unwrap();
assert_eq!(lsp.risk, ide_extension::Risk::High);
assert!(lsp.optional, "declaration-derived capabilities can be refused");
// Refused: the language still works.
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
assert_eq!(f.host.languages().len(), 1);
assert!(f.host.program_for("language-toy", "toylsp").is_err());
// Accepted: pinned to the resolved file.
f.host.set_capability("language-toy", "process:toylsp", true).unwrap();
let resolved = f.host.program_for("language-toy", "toylsp").unwrap();
assert!(resolved.ends_with("toylsp"));
std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho replaced\n").unwrap();
let err = f.host.program_for("language-toy", "toylsp").unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(err.message.contains("confirm"), "{}", err.message);
}
/// A declaration-only language plugin reaches detection and LSP of
/// every project; its server starts only while `process:` is granted, from
/// the approved file.
#[cfg(unix)]
#[test]
fn plugin_languages_join_the_registry_and_start_only_granted_servers() {
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir(&bin).unwrap();
std::fs::write(bin.join("toylsp"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(bin.join("toylsp"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone();
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
let registry = LanguageRegistry::load(None).unwrap();
registry.attach_plugins(f.host.language_store().clone());
let project = registry.fork();
let toy = LanguageId::new("toy");
assert_eq!(project.detect(Path::new("/p/a.toy"), None), LanguageId::plain_text());
let pkg = tmp.path().join("pkg");
copy_dir(&sample("language-toy"), &pkg);
let lang = pkg.join("languages/toy.toml");
let text = std::fs::read_to_string(&lang).unwrap();
std::fs::write(&lang, format!("{text}\n[[language-servers]]\nname = \"toylsp\"\ncommand = \"toylsp\"\n")).unwrap();
// Refused: detected and listed, no server.
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
assert_eq!(project.detect(Path::new("/p/a.toy"), None), toy);
assert!(registry.all().iter().any(|l| l.id == toy), "the UI language list includes it");
assert!(project.get(&toy).unwrap().language_servers.is_empty());
// Granted: the server is pinned to the approved file.
f.host.set_capability("language-toy", "process:toylsp", true).unwrap();
let server = project.get(&toy).unwrap().language_servers[0].clone();
let pin = server.pinned.expect("pinned");
assert_eq!(pin.verify().unwrap(), dunce::canonicalize(bin.join("toylsp")).unwrap());
// Replaced afterwards: refused when it would start.
std::fs::write(bin.join("toylsp"), "#!/bin/sh\necho replaced\n").unwrap();
assert!(pin.verify().unwrap_err().contains("changed since it was approved"));
// Revoked, then disabled.
f.host.set_capability("language-toy", "process:toylsp", false).unwrap();
assert!(project.get(&toy).unwrap().language_servers.is_empty());
f.host.set_enabled("language-toy", false).unwrap();
assert_eq!(project.detect(Path::new("/p/a.toy"), None), LanguageId::plain_text());
}
/// A plugin provides the syntax of a language the IDE has no grammar
/// for (the official Solidity plugin, built with the SDK), activated on first use.
#[test]
fn plugin_syntax_for_languages_without_a_grammar() {
use ide_core::{DocId, LanguageId, SymbolKind};
use ide_language::{Syntax, SyntaxCache, SyntaxHighlightKind};
let tmp = tempfile::tempdir().unwrap();
let host = Arc::new(
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(),
);
host.load_all();
host.startup_finished();
let info = host.install_from_dir(&repo().join("plugins/official/solidity"), &Consent::default()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert!(info.capabilities.iter().all(|c| c.optional && !c.granted), "language servers were refused");
let provider = host.syntax_provider();
let sol = LanguageId::new("solidity");
let text = "// c\ncontract A {\n uint256 public x;\n function f() public {}\n}\n";
assert!(provider.highlights(&LanguageId::new("rust"), text).is_none(), "only its own languages");
// The first call of a new instance can run past the edit-path budget on a
// slow machine; its answer is dropped and a later call asks again.
let deadline = Instant::now() + Duration::from_secs(10);
let tokens = loop {
match provider.highlights(&sol, text) {
Some(tokens) => break tokens,
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
None => panic!("no highlights"),
}
};
let kind_at =
|s: &str| tokens.iter().find(|t| &text[t.start as usize..t.end as usize] == s).map(|t| t.kind.as_str());
assert_eq!(kind_at("contract"), Some("keyword"));
assert_eq!(kind_at("uint256"), Some("type"));
assert_eq!(kind_at("// c"), Some("comment"));
assert_eq!(host.info_of("openidea.solidity").unwrap().status, PluginStatus::Active, "activated on first use");
// As the editor gets it: through a project's syntax cache.
ide_language::set_external_syntax(Some(provider.clone()));
let cache = SyntaxCache::new(Arc::new(Syntax::new()));
let outline = cache.outline(DocId(1), &sol, 1, text);
assert_eq!(outline.len(), 1);
assert_eq!((outline[0].name.as_str(), outline[0].kind), ("A", SymbolKind::Class));
let members: Vec<_> = outline[0].children.iter().map(|c| (c.name.as_str(), c.kind)).collect();
assert_eq!(members, [("x", SymbolKind::Field), ("f", SymbolKind::Method)]);
assert_eq!(outline[0].selection_range.start.line, 1);
let folds = cache.folding(DocId(1), &sol, 1, text);
assert!(folds.iter().any(|f| f.start_line == 1 && f.end_line == 4));
let highlights = cache.highlights(DocId(1), &sol, 1, text);
let keyword = SyntaxHighlightKind::ALL.iter().position(|k| *k == SyntaxHighlightKind::Keyword).unwrap() as u32;
assert!(highlights.data.chunks(3).any(|t| t == [5, 13, keyword]), "UTF-16 offsets of `contract`");
ide_language::set_external_syntax(None);
}
/// Plugins add run configurations to `ide-run`'s detection; the
/// command runs only while granted, from the approved file.
#[cfg(unix)]
#[test]
fn run_detectors_offer_granted_commands_in_marked_projects() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("bin");
std::fs::create_dir(&bin).unwrap();
std::fs::write(bin.join("forge"), "#!/bin/sh\n").unwrap();
std::fs::set_permissions(bin.join("forge"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone();
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
let pkg = tmp.path().join("pkg");
std::fs::create_dir(&pkg).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
"id = \"acme.forge\"\nname = \"Forge\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.runDetectors]]\nid = \"test\"\nname = \"forge test\"\nfiles = [\"foundry.toml\"]\n\
command = \"forge\"\nargs = [\"test\"]\ntest = true\n",
)
.unwrap();
let preview = f.host.preview_install(&pkg).unwrap();
assert!(preview.capabilities.iter().any(|c| c.id == "process:forge" && c.optional));
f.host.install_from_dir(&pkg, &Consent::default()).unwrap();
let detection = f.host.run_detection();
let project = tmp.path().join("chain");
std::fs::create_dir(&project).unwrap();
assert!(detection.detect(&project).is_empty(), "no marker");
std::fs::write(project.join("foundry.toml"), "").unwrap();
assert!(detection.detect(&project).is_empty(), "not granted");
f.host.set_capability("acme.forge", "process:forge", true).unwrap();
let configs = detection.detect(&project);
assert_eq!(configs.len(), 1);
let config = &configs[0];
assert_eq!(
(config.id.as_str(), config.name.as_str(), config.is_test),
("plugin:acme.forge:test", "forge test", true)
);
assert_eq!(detection.launch(config).unwrap(), dunce::canonicalize(bin.join("forge")).unwrap());
std::fs::write(bin.join("forge"), "#!/bin/sh\necho swapped\n").unwrap();
assert!(detection.launch(config).unwrap_err().contains("changed since it was approved"));
f.host.set_enabled("acme.forge", false).unwrap();
assert!(detection.detect(&project).is_empty());
assert!(detection.launch(config).unwrap_err().contains("no longer enabled"));
}
/// Compiled components are cached across runs (keyed by module and
/// engine), so a cold activation only deserializes.
#[test]
fn precompiled_components_make_cold_activation_cheap() {
let f = fixture(Limits::default());
f.install(&sample("hello"));
let cache = f.plugins_dir.join(".cache/components");
let cached = || std::fs::read_dir(&cache).map(|d| d.count()).unwrap_or(0);
assert_eq!(cached(), 1, "install validation compiled it once, into the cache");
// Another run of the IDE: activation deserializes.
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
let started = Instant::now();
let _ = again.run_command("hello", "word-count", None, Invocation::default());
let cold = started.elapsed();
let stats = again.info_of("hello").unwrap().stats;
assert_eq!(stats.activations, 1);
eprintln!("cold activation with a precompiled component: {:.1} ms (wall {cold:?})", stats.activation_ms);
assert!(stats.activation_ms < 250.0, "{} ms", stats.activation_ms);
// A stale entry (other engine or uninstalled plugin) is pruned by the warm-up.
std::fs::write(cache.join("0000-stale.cwasm"), b"junk").unwrap();
again.warm_component_cache();
assert_eq!(cached(), 1);
// A corrupt entry is discarded and recompiled. The host that loaded it goes
// first: its component maps the file (which Windows won't let anyone replace).
drop(again);
for e in std::fs::read_dir(&cache).unwrap().flatten() {
std::fs::write(e.path(), b"junk").unwrap();
}
let third = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
third.load_all();
assert!(
third.run_command("hello", "word-count", None, Invocation::default()).is_err(),
"no active file, but it runs"
);
assert_eq!(third.info_of("hello").unwrap().status, PluginStatus::Active);
}
#[test]
fn install_validation_atomic_replace_and_uninstall() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
// Missing component file.
let pkg = tmp.path().join("broken");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
"id = \"b\"\nname = \"B\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\nwasm = \"x.wasm\"",
)
.unwrap();
let install = |p: &Path| f.host.install_from_dir(p, &Consent::all());
assert!(install(&pkg).unwrap_err().message.contains("missing"));
// Not a component.
std::fs::write(pkg.join("x.wasm"), b"\0asm garbage").unwrap();
assert!(install(&pkg).unwrap_err().message.contains("not a valid plugin component"));
// Bad manifest.
std::fs::write(pkg.join("plugin.toml"), "id = \"B!\"").unwrap();
assert!(install(&pkg).is_err());
assert!(f.host.list(None).plugins.is_empty(), "failed installs leave nothing behind");
// A broken package already in the plugins dir shows up as failed.
std::fs::create_dir_all(f.plugins_dir.join("junk")).unwrap();
std::fs::write(f.plugins_dir.join("junk/plugin.toml"), "garbage = ").unwrap();
f.host.load_all();
let junk = &f.host.list(None).plugins[0];
assert_eq!(junk.status, PluginStatus::Failed);
assert!(junk.error.is_some());
// Reinstall replaces; a failing reinstall keeps the installed version.
f.install(&sample("hello"));
f.install(&sample("hello"));
assert_eq!(f.status("hello"), PluginStatus::Inactive);
let bad = hello_variant(tmp.path(), |t| t.replace("version = \"0.3.0\"", "version = \"0.9.0\""));
std::fs::write(bad.join("dist/plugin.wasm"), b"not wasm").unwrap();
assert!(install(&bad).is_err());
assert_eq!(f.host.info_of("hello").unwrap().version, "0.3.0");
assert!(f.installed("hello").join("dist/plugin.wasm").is_file());
assert!(f.installed("hello").join(".package-hash").is_file());
let leftovers: Vec<_> = std::fs::read_dir(&f.plugins_dir)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(".staging"))
.collect();
assert!(leftovers.is_empty());
f.host.uninstall("hello").unwrap();
assert!(!f.plugins_dir.join("hello").exists());
assert!(f.host.info_of("hello").is_err());
}
#[test]
fn unknown_contribution_points_are_skipped_with_a_warning() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("future");
copy_dir(&sample("language-toy"), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap();
std::fs::write(&manifest, format!("{text}\n[[contributes.holograms]]\nshape = \"cube\"\n")).unwrap();
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static);
assert!(info.warnings.iter().any(|w| w.contains("holograms")));
assert_eq!(f.host.languages().len(), 1);
}
#[test]
fn icon_themes_are_a_contribution_type_like_any_other() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("icons");
std::fs::create_dir_all(pkg.join("svg")).unwrap();
std::fs::write(pkg.join("svg/rust.svg"), r#"<svg viewBox="0 0 16 16"><path d="M0 0h16"/></svg>"#).unwrap();
std::fs::write(pkg.join("svg/file.svg"), r#"<svg viewBox="0 0 16 16"><rect width="9" height="9"/></svg>"#).unwrap();
std::fs::write(
pkg.join("theme.json"),
r#"{"iconDefinitions":{"rust":"svg/rust.svg","file":"svg/file.svg"},"file":"file","fileExtensions":{"rs":"rust"}}"#,
)
.unwrap();
std::fs::write(
pkg.join("plugin.toml"),
"id = \"acme.icons\"\nname = \"Icons\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.iconThemes]]\nid = \"acme\"\nlabel = \"Acme Icons\"\npath = \"theme.json\"\n",
)
.unwrap();
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
assert!(f.installed("acme.icons").join("svg/rust.svg").is_file(), "referenced files are installed");
let items = f.host.ui_items("iconThemes");
assert_eq!(items.len(), 1);
assert_eq!(items[0].decl["label"], "Acme Icons");
let svgs = f.host.assets("acme.icons", &icon_theme_svg_prefix("acme")).unwrap();
assert_eq!(svgs.len(), 2);
let map = f.host.assets("acme.icons", &icon_theme_map_asset("acme")).unwrap();
assert!(map[0].1.contains("\"rs\":\"rust\""));
f.host.set_enabled("acme.icons", false).unwrap();
assert!(f.host.ui_items("iconThemes").is_empty());
assert!(f.host.assets("acme.icons", "").is_err());
}
#[test]
fn official_material_icons_install() {
let f = fixture(Limits::default());
let info = f.install(&repo().join("plugins/official/material-icons"));
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let theme: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(repo().join("plugins/official/material-icons/icons/theme.json")).unwrap(),
)
.unwrap();
let defined = theme["iconDefinitions"].as_object().unwrap().len();
let svgs = f.host.assets("openidea.material-icons", &icon_theme_svg_prefix("material")).unwrap();
assert_eq!(svgs.len(), defined, "every icon survives the sanitiser");
let map = &f.host.assets("openidea.material-icons", &icon_theme_map_asset("material")).unwrap()[0].1;
assert!(map.contains("\"src\":\"folder-src\""), "folder names are part of the mapping");
assert!(
!f.installed("openidea.material-icons").join("generate.py").exists(),
"only referenced files are installed"
);
}
/// An icon theme with thousands of SVGs (sanitising, package size, start-up).
#[test]
fn thousands_of_icons_install_and_reload_within_budget() {
const ICONS: usize = 3000;
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("many");
std::fs::create_dir_all(pkg.join("svg")).unwrap();
let mut defs = serde_json::Map::new();
let mut exts = serde_json::Map::new();
for i in 0..ICONS {
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="#{:06x}"/><path fill="#fff" fill-opacity=".4" d="M4 1h5.6L13 4.4V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1z"/><text x="8" y="11" font-family="system-ui, sans-serif" font-size="7" font-weight="700" text-anchor="middle" fill="#fff">{}</text></svg>"##,
i * 2654435 % 0xff_ffff,
i % 100
);
std::fs::write(pkg.join(format!("svg/i{i}.svg")), svg).unwrap();
defs.insert(format!("i{i}"), format!("svg/i{i}.svg").into());
exts.insert(format!("x{i}"), format!("i{i}").into());
}
let theme = serde_json::json!({ "iconDefinitions": defs, "file": "i0", "fileExtensions": exts });
std::fs::write(pkg.join("theme.json"), theme.to_string()).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
"id = \"acme.many\"\nname = \"Many\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\
[[contributes.iconThemes]]\nid = \"many\"\nlabel = \"Many\"\npath = \"theme.json\"\n",
)
.unwrap();
let started = Instant::now();
let info = f.install(&pkg);
let install = started.elapsed();
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
assert_eq!(f.host.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS);
let started = Instant::now();
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
let reload = started.elapsed();
assert_eq!(again.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap().len(), ICONS);
eprintln!("{ICONS} icons: install {install:?}, load at start-up {reload:?} (from the contribution index)");
// Guards against work that grows faster than the icon count, not a
// benchmark: a debug build takes ~1.6 s / ~0.35 s on a laptop; shared CI
// runners running the suite in parallel take several times that (Windows
// ~11 s to install).
assert!(install < Duration::from_secs(30), "install took {install:?}");
assert!(reload < Duration::from_secs(10), "start-up load took {reload:?}");
// Start-up reads the validated declaration from the index, not the SVGs:
// a tampered icon file is not even looked at...
let index = f.plugins_dir.join(".cache/contributions");
assert_eq!(std::fs::read_dir(&index).unwrap().count(), 1);
std::fs::write(f.installed("acme.many").join("svg/i1.svg"), "<svg><script>alert(1)</script></svg>").unwrap();
let third = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
third.load_all();
let icon = third.assets("acme.many", &icon_theme_svg_prefix("many")).unwrap();
assert!(icon.iter().all(|(_, svg)| !svg.contains("script")));
// ...and without the index everything is validated again.
std::fs::remove_dir_all(&index).unwrap();
let fourth = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
fourth.load_all();
assert_eq!(fourth.info_of("acme.many").unwrap().status, PluginStatus::Failed, "the tampered icon is rejected");
}
#[test]
fn linked_development_plugins() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("toy");
copy_dir(&sample("language-toy"), &pkg);
let info = f.host.link(&pkg).unwrap();
assert_eq!(info.source, PluginSource::Linked);
assert_eq!(info.status, PluginStatus::NeedsConsent);
f.host.grant("language-toy", &Consent::default()).unwrap();
assert_eq!(f.host.languages().len(), 1);
// Survives a restart (links.json) and keeps its grant (by path).
let again = PluginHost::new(f.plugins_dir.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
again.load_all();
assert_eq!(again.info_of("language-toy").unwrap().status, PluginStatus::Static);
again.unlink("language-toy").unwrap();
assert!(again.info_of("language-toy").is_err());
}
#[test]
fn catalog_lists_sample_packages() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let broken = tmp.path().join("broken");
std::fs::create_dir_all(&broken).unwrap();
std::fs::write(
broken.join("plugin.toml"),
"id = \"x\"\nname = \"X\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\nwasm = \"dist/plugin.wasm\"\n",
)
.unwrap();
std::fs::create_dir_all(tmp.path().join("junk")).unwrap();
std::fs::write(tmp.path().join("junk/plugin.toml"), "garbage = ").unwrap();
let catalog = f.host.catalog(&[
repo().join("crates/ide-plugin/tests/fixtures"),
repo().join("plugins/official"),
tmp.path().to_path_buf(),
]);
let ids: Vec<&str> = catalog.iter().map(|c| c.id.as_str()).collect();
for id in ["hello", "openidea.harbor-night", "language-toy", "x"] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
let hello = catalog.iter().find(|c| c.id == "hello").unwrap();
assert!(hello.has_code && hello.problem.is_none() && hello.change_notes.is_some());
assert_eq!(hello.contributions.iter().find(|c| c.point == "commands").unwrap().count, 2);
let theme = catalog.iter().find(|c| c.id == "openidea.harbor-night").unwrap();
assert!(!theme.has_code);
assert_eq!(theme.contributions[0].count, 1);
let x = catalog.iter().find(|c| c.id == "x").unwrap();
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
}
fn official(name: &str) -> PathBuf {
repo().join("plugins/official").join(name)
}
/// String Tools: 26 commands show before the code loads; one command
/// edits every selection as one versioned change.
#[test]
fn official_string_tools() {
let f = fixture(Limits::default());
let info = f.install(&official("string-tools"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let titles = f.command_titles("openidea.string-tools");
assert_eq!(titles.len(), 26);
assert!(titles.contains(&"String Tools: To snake_case".to_owned()));
let categories: std::collections::BTreeSet<String> =
f.host.ui_items(COMMANDS).iter().filter_map(|c| c.decl["category"].as_str().map(str::to_owned)).collect();
assert_eq!(categories.len(), 4, "{categories:?}");
let editor = f.workspace.open("src/notes.txt", &[(0, 0, 0, 3), (1, 5, 1, 5)]);
let before = f.workspace.document(&editor.path).unwrap().0;
f.run_in(&editor, "openidea.string-tools", "upper-case").unwrap();
assert_eq!(f.workspace.text("src/notes.txt"), "ONE two three\nfour FIVE\n");
assert_eq!(f.workspace.document(&editor.path).unwrap().0, before + 1, "both selections in one change");
}
/// Key Promoter: action events are batched; an idle plugin is
/// woken once per batch, and its counts persist in storage.
#[test]
fn official_key_promoter() {
let f = fixture(Limits::default());
f.install(&official("key-promoter"));
assert!(f.host.event_kinds().contains(&EventKind::ActionPerformed), "subscribed by its manifest");
let menu = |action: &str| {
WorkbenchEvent::ActionPerformed(ActionPerformed {
action: action.into(),
title: "Duplicate Line".into(),
source: ActionSource::Menu,
shortcut: Some("Ctrl+D".into()),
})
};
let typed = WorkbenchEvent::ActionPerformed(ActionPerformed {
action: "editor.copy".into(),
title: "Copy".into(),
source: ActionSource::Keyboard,
shortcut: Some("Ctrl+C".into()),
});
f.take_events();
let now = Instant::now();
f.host.post_events(Some("main"), vec![menu("code.duplicate"), typed]);
f.host.flush_events_at(now);
assert_eq!(f.host.instances(), 0, "an idle plugin collects a batch first");
f.host.post_events(Some("main"), vec![menu("code.duplicate"), menu("code.duplicate")]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let info = f.host.info_of("openidea.key-promoter").unwrap();
assert_eq!((info.status, info.stats.activations, info.stats.calls), (PluginStatus::Active, 1, 1), "one wake-up");
let tips: Vec<_> = f
.take_events()
.into_iter()
.filter_map(|(client, e)| match e {
PluginEvent::Notification { title, body, .. } => Some((client, title, body)),
_ => None,
})
.collect();
assert_eq!(tips.len(), 1, "{tips:?}");
assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from");
assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}");
let stored = stored(&f, "openidea.key-promoter");
assert!(stored.contains(&("count:code.duplicate".into(), "3".into())), "{stored:?}");
// Uninstalling removes its data.
f.host.uninstall("openidea.key-promoter").unwrap();
assert!(!f.plugins_dir.join(".data/openidea.key-promoter").exists());
assert!(f.host.event_kinds().is_empty());
}
#[test]
fn storage_keeps_multimegabyte_indexes_after_idle_reclaim_and_removes_them_on_uninstall() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
let pkg = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/storage");
f.install(&pkg);
f.run("test.storage", "save", None).unwrap();
assert_eq!(f.host.instances(), 1);
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.host.instances(), 0);
assert_eq!(f.host.info_of("test.storage").unwrap().stats.reclaims, 1);
assert_eq!(f.run("test.storage", "load", None).unwrap().as_deref(), Some("4194304"));
assert_eq!(f.host.info_of("test.storage").unwrap().stats.activations, 2);
let storage_dir = f.plugins_dir.join(".data/test.storage");
assert_eq!(std::fs::read_dir(storage_dir.join("storage")).unwrap().count(), 1, "one file per key");
f.host.uninstall("test.storage").unwrap();
assert!(!storage_dir.exists());
f.install(&pkg);
assert!(f.run("test.storage", "load", None).unwrap_err().contains("missing index"));
}
/// A plugin's stored entries (key, value), read from its files.
fn stored(f: &Fixture, plugin: &str) -> Vec<(String, String)> {
let dir = f.plugins_dir.join(".data").join(plugin).join("storage");
let mut entries: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| {
let bytes = std::fs::read(e.unwrap().path()).unwrap();
let len = usize::from(u16::from_le_bytes([bytes[0], bytes[1]]));
let key = String::from_utf8(bytes[2..2 + len].to_vec()).unwrap();
(key, String::from_utf8(bytes[2 + len..].to_vec()).unwrap())
})
.collect();
entries.sort();
entries
}
fn git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
.args(args)
.current_dir(dir)
.output()
.expect("git");
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
}
/// Git Toolbox's scheduled fetch still runs after the
/// instance was reclaimed while idle, and the status bar follows.
#[test]
fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
let tmp = tempfile::tempdir().unwrap();
// The project tracks a remote that gets one more commit.
let remote = tmp.path().join("remote.git");
std::fs::create_dir(&remote).unwrap();
git(&remote, &["init", "--bare", "-b", "main"]);
git(&f.project, &["init", "-b", "main"]);
git(&f.project, &["add", "."]);
git(&f.project, &["commit", "-m", "one"]);
git(&f.project, &["remote", "add", "origin", remote.to_str().unwrap()]);
git(&f.project, &["push", "-u", "origin", "main"]);
let other = tmp.path().join("other");
git(tmp.path(), &["clone", remote.to_str().unwrap(), other.to_str().unwrap()]);
std::fs::write(other.join("b.txt"), "b").unwrap();
git(&other, &["add", "."]);
git(&other, &["commit", "-m", "two"]);
git(&other, &["push"]);
f.install(&official("git-toolbox"));
// Focusing an editor wakes it to show the branch (nothing fetched yet).
let now = Instant::now();
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
f.host.flush_events_at(now + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
let item = |host: &PluginHost| {
host.status_items().into_iter().find(|(p, id, _)| p == "openidea.git-toolbox" && id == "branch")
};
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main".into()));
// Idle: reclaimed.
std::thread::sleep(Duration::from_millis(100));
f.host.tick();
assert_eq!(f.host.instances(), 0);
assert_eq!(f.status("openidea.git-toolbox"), PluginStatus::Inactive);
assert!(item(&f.host).is_some(), "its status item stays while it sleeps");
// Five minutes later: activated by its schedule, fetches, shows ↓1.
f.take_events();
f.host.tick_at(Instant::now() + Duration::from_secs(301));
assert!(f.host.wait_events(Duration::from_secs(30)));
let info = f.host.info_of("openidea.git-toolbox").unwrap();
assert_eq!(info.stats.activations, 2, "{:?}", info.error);
assert_eq!(item(&f.host).map(|(_, _, i)| i.text), Some("main ↓1".into()));
let events = f.take_events();
assert!(
events.iter().any(
|(_, e)| matches!(e, PluginEvent::TaskStarted { title, cancellable: true, .. } if title == "Fetching")
)
);
assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. })));
let stored = stored(&f, "openidea.git-toolbox");
assert!(stored.iter().any(|(key, _)| key.starts_with("last-fetch:")), "{stored:?}");
}
/// Restricted Mode: git does not run in an untrusted project (no fetch).
#[test]
fn git_toolbox_does_not_fetch_untrusted_projects() {
let f = fixture(Limits::default());
git(&f.project, &["init", "-b", "main"]);
f.install(&official("git-toolbox"));
let untrusted = Arc::new(TestWorkspace {
project_queries_left: Mutex::new(None),
root: f.project.clone(),
other_roots: Mutex::default(),
documents: f.workspace.documents.clone(),
trusted: false,
event_read_gate: Mutex::default(),
diagnostics: Mutex::default(),
settings: Mutex::default(),
global_settings: Mutex::default(),
});
f.host.set_workspace(Some(untrusted));
let err = f.run("openidea.git-toolbox", "fetch", None).unwrap_err();
assert!(err.contains("Restricted Mode"), "{err}");
}
/// A tiny HTTP server: each request is answered after `delay(n)` (n counts
/// requests) with `body`. Returns its port and the request bodies it got.
fn http_server(
body: &'static str,
delay: impl Fn(usize) -> Duration + Send + Sync + 'static,
) -> (u16, Arc<Mutex<Vec<String>>>) {
use std::io::{BufRead, BufReader, Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let seen: Arc<Mutex<Vec<String>>> = Arc::default();
let (log, delay) = (seen.clone(), Arc::new(delay));
std::thread::spawn(move || {
for (n, stream) in listener.incoming().flatten().enumerate() {
let (log, delay) = (log.clone(), delay.clone());
std::thread::spawn(move || {
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut len = 0;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
}
let mut req = vec![0; len];
let _ = reader.read_exact(&mut req);
log.lock().push(String::from_utf8_lossy(&req).into_owned());
std::thread::sleep(delay(n));
let mut stream = stream;
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
});
}
});
(port, seen)
}
/// AI Completion: typing on cancels the previous request (the fetch
/// returns `cancelled`, the instance stays); the plugin's own work stays
/// within the edit-path budget.
#[test]
fn official_ai_completion_cancels_superseded_requests() {
let f = fixture(Limits::default());
f.install(&official("ai-completion"));
let (port, seen) = http_server(r#"{"choices":[{"text":"a + b\n"}]}"#, |n| {
if n == 0 { Duration::from_secs(3) } else { Duration::ZERO }
});
f.run("openidea.ai-completion", "set-endpoint", Some(&format!("http://localhost:{port}"))).unwrap();
let doc = ide_plugin::ProviderDocument {
path: f.project.join("src/a.rs"),
language: "rust".into(),
version: 1,
text: "fn add(a: i32, b: i32) -> i32 {\n \n}\n".into(),
};
let at = ide_core::Pos::new(1, 4);
let host = Arc::new(f);
// A slow request, superseded by the next keystroke's once the server has it
// (starting the instance comes first, and takes longer on a slow runner).
let first = ide_core::CancellationToken::new();
let slow = {
let (host, doc, first) = (host.clone(), doc.clone(), first.clone());
std::thread::spawn(move || (host.host.provide_inline_completion(&doc, at, &first), Instant::now()))
};
assert!(eventually(|| seen.lock().len() == 1), "the first request reaches the server");
let cancelled = Instant::now();
first.cancel();
let started = Instant::now();
let second = host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new());
let took = started.elapsed();
let (old, old_done) = slow.join().unwrap();
let old_took = old_done.saturating_duration_since(cancelled);
assert!(old.is_none(), "the superseded request yields nothing");
assert!(old_took < Duration::from_secs(1), "cancelled, not waited out: {old_took:?}");
assert_eq!(second.map(|(_, s)| s.insert_text).as_deref(), Some("a + b"));
eprintln!("inline completion after cancelling the previous request: {took:?}");
let info = host.host.info_of("openidea.ai-completion").unwrap();
assert_eq!((info.stats.crashes, info.stats.activations), (0, 1), "cooperative: same instance");
assert!(seen.lock()[1].contains("\"prompt\":\"fn add(a: i32, b: i32) -> i32 {\\n \""), "{:?}", seen.lock());
// The plugin's own share of a call (local server, no delay): edit-path budget.
let mut times = Vec::new();
for _ in 0..10 {
let started = Instant::now();
assert!(host.host.provide_inline_completion(&doc, at, &ide_core::CancellationToken::new()).is_some());
times.push(started.elapsed());
}
times.sort();
eprintln!("inline completion round trip p95 (local server): {:?}", times[9]);
assert!(times[9] < Duration::from_millis(500), "{times:?}");
}
/// Checkstyle: a save runs the approved `checkstyle` and publishes
/// diagnostics; quick fixes come through the code-action provider; saving
/// again cancels the check still running.
#[cfg(unix)]
#[test]
fn official_checkstyle_checks_on_save() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits::default());
let bin = f.plugins_dir.parent().unwrap().join("bin");
std::fs::create_dir_all(&bin).unwrap();
// Reports a tab on line 2; files containing SLOW take 5 s.
std::fs::write(
bin.join("checkstyle"),
"#!/bin/sh\nfor a; do file=\"$a\"; done\ngrep -q SLOW \"$file\" && sleep 5\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();
let path = bin.clone();
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
let info = f.install(&official("checkstyle"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let java = f.project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = |f: &Fixture| {
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
};
saved(&f);
assert!(f.host.wait_events(Duration::from_secs(20)));
let diagnostics =
|f: &Fixture| f.workspace.diagnostics.lock().get(&("openidea.checkstyle".into(), java.clone())).cloned();
let d = diagnostics(&f).expect("published");
assert_eq!((d.len(), d[0].code.as_deref(), d[0].range.start.line), (1, Some("FileTabCharacter"), 1));
// The quick fix, through the provider (as the editor asks for it).
let doc = ide_plugin::ProviderDocument {
path: java.clone(),
language: "java".into(),
version: 1,
text: std::fs::read_to_string(&java).unwrap(),
};
let range = ide_core::Range::new(ide_core::Pos::new(1, 0), ide_core::Pos::new(1, 0));
let actions = f.host.provide_code_actions(&doc, range);
assert_eq!(actions.len(), 1, "{actions:?}");
assert_eq!(actions[0].1.title, "Checkstyle: Replace tabs with spaces");
assert_eq!(actions[0].1.edits[0].1[0].new_text, " int x;");
// A slow check is cancelled by the next save.
f.workspace.diagnostics.lock().clear();
std::fs::write(&java, "class A { // SLOW\n\tint x;\n}\n").unwrap();
let started = Instant::now();
saved(&f);
std::thread::sleep(Duration::from_millis(300));
std::fs::write(&java, "class A {\n\tint y;\n}\n").unwrap();
saved(&f);
assert!(f.host.wait_events(Duration::from_secs(20)));
assert!(started.elapsed() < Duration::from_secs(4), "the slow run was killed: {:?}", started.elapsed());
assert_eq!(diagnostics(&f).map(|d| d.len()), Some(1), "published by the second check");
let info = f.host.info_of("openidea.checkstyle").unwrap();
assert_eq!(info.stats.crashes, 0);
}
/// License Header: reported when a file opens, fixed through the provider,
/// and a project scan in a task reports the rest.
#[test]
fn official_license_header_reports_fixes_and_scans() {
let f = fixture(Limits::default());
let info = f.install(&official("license-header"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let a = f.project.join("src/a.rs");
let b = f.project.join("src/b.py");
std::fs::write(&a, "fn a() {}\n").unwrap();
std::fs::write(&b, "#!/usr/bin/env python3\nprint()\n").unwrap();
let diagnostics = |path: &Path| {
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
};
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
let d = diagnostics(&a).expect("published on open");
assert_eq!((d.len(), d[0].code.as_deref()), (1, Some("missing-license-header")));
assert!(diagnostics(&b).is_none(), "only the opened file");
let doc = ide_plugin::ProviderDocument {
path: b.clone(),
language: "python".into(),
version: 1,
text: std::fs::read_to_string(&b).unwrap(),
};
let actions = f.host.provide_code_actions(&doc, ide_core::Range::default());
assert_eq!(actions.len(), 1, "{actions:?}");
let edit = &actions[0].1.edits[0].1[0];
assert_eq!(edit.range.start.line, 1, "after the shebang");
assert!(
edit.new_text.starts_with("# Copyright (c) 20")
&& edit.new_text.contains("# SPDX-License-Identifier: Apache-2.0")
);
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
// Disabling the plugin withdraws the problems it still shows.
f.host.set_enabled("openidea.license-header", false).unwrap();
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(0), "withdrawn with the plugin");
}
/// MyBatis Navigator: interface ↔ XML through the providers, on a
/// real project tree (`find-files`), within the edit-path budget.
#[test]
fn official_mybatis_navigates_between_interface_and_xml() {
let f = fixture(Limits::default());
f.install(&official("mybatis"));
let java_path = f.project.join("src/main/java/com/acme/UserMapper.java");
let xml_path = f.project.join("src/main/resources/mapper/UserMapper.xml");
std::fs::create_dir_all(java_path.parent().unwrap()).unwrap();
std::fs::create_dir_all(xml_path.parent().unwrap()).unwrap();
let java = "package com.acme;\n\npublic interface UserMapper {\n User findById(long id);\n}\n";
let xml = "<mapper namespace=\"com.acme.UserMapper\">\n <select id=\"findById\">select 1</select>\n</mapper>\n";
std::fs::write(&java_path, java).unwrap();
std::fs::write(&xml_path, xml).unwrap();
// Noise the index has to skip.
for i in 0..200 {
std::fs::write(f.project.join(format!("src/f{i}.xml")), "<beans/>").unwrap();
}
let doc = |path: &Path, language: &str, text: &str| ide_plugin::ProviderDocument {
path: path.to_path_buf(),
language: language.into(),
version: 1,
text: text.into(),
};
let jdoc = doc(&java_path, "java", java);
// The first call indexes the XML files; on a slow machine that runs past the
// edit-path budget, so the answer is dropped (the index is kept) and calls
// queued behind it are skipped. The editor asks again; so does this.
let deadline = Instant::now() + Duration::from_secs(10);
let markers = loop {
let markers = f.host.provide_line_markers(&jdoc);
if !markers.is_empty() || Instant::now() > deadline {
break markers;
}
std::thread::sleep(Duration::from_millis(50));
};
assert_eq!(markers.len(), 1, "{markers:?}");
let target = markers[0].1.target.clone().unwrap();
assert_eq!((target.path, target.range.start), (xml_path.clone(), ide_core::Pos::new(1, 14)));
let xdoc = doc(&xml_path, "xml", xml);
// Reads the interface from disk: on a slow machine that too can run past the budget.
let mut to_java = Vec::new();
assert!(eventually(|| {
to_java = f.host.provide_definition(&xdoc, ide_core::Pos::new(1, 16));
!to_java.is_empty()
}));
assert_eq!(
to_java.iter().map(|l| (l.path.clone(), l.range.start)).collect::<Vec<_>>(),
[(java_path.clone(), ide_core::Pos::new(3, 9))]
);
assert!(
f.host.provide_definition(&doc(&f.project.join("src/a.rs"), "rust", ""), ide_core::Pos::new(0, 0)).is_empty(),
"not its language"
);
// Warm (index cached): well inside the edit-path budget.
let slow = f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls;
let mut times = Vec::new();
for _ in 0..20 {
let started = Instant::now();
assert_eq!(f.host.provide_line_markers(&jdoc).len(), 1);
times.push(started.elapsed());
}
times.sort();
eprintln!("mybatis line markers p95: {:?}", times[18]);
assert!(times[18] < Duration::from_millis(50), "{times:?}");
assert_eq!(f.host.info_of("openidea.mybatis").unwrap().stats.slow_calls, slow);
}
/// CSV Editor: a custom editor renders the document as a table (the
/// rows asked for), and edits the document itself — versioned, so a text edit
/// made meanwhile is never overwritten.
#[test]
fn official_csv_editor_edits_the_shared_document() {
let f = fixture(Limits::default());
let info = f.install(&official("csv-editor"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("people.csv"), "name,city\nAda,London\nBob,Oslo\n").unwrap();
let editor = f.workspace.open("people.csv", &[]);
let inv = Invocation { editor: Some(editor.clone()), ..f.inv() };
let viewport = Some(ide_plugin::Viewport { first_row: 1, rows: 10 });
let view = f.host.render_view("openidea.csv-editor", "table", viewport, inv.clone()).unwrap();
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
assert_eq!((t.columns.clone(), t.total_rows, t.first_row), (vec!["name".to_owned(), "city".into()], 2, 1));
assert_eq!(t.rows, [vec!["Bob".to_owned(), "Oslo".into()]]);
assert_eq!(view.status.as_deref(), Some("2 rows × 2 columns, delimiter ','"));
let cell = ide_plugin::ViewEvent::Cell { row: 0, column: 1, value: "Paris, FR".into() };
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
// Settings: without a header row every record is a row.
let mut values = serde_json::Map::new();
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
// Undeclared views are refused before any code runs.
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn context_menu_entries_can_name_a_submenu() {
fn with_menu(version: &'static str, extra: &'static str) -> impl Fn(&str) -> String {
move |m: &str| {
let m = m.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{version}\""));
format!("{m}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n")
}
}
let f = fixture(Limits::default());
let dir = tempfile::tempdir().unwrap();
let ok = hello_variant(dir.path(), with_menu("^1.3", "menu = \"editor.context\"\nsubmenu = \"Words\""));
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
let submenus: Vec<_> =
f.host.ui_items("menus").into_iter().filter_map(|i| i.decl["submenu"].as_str().map(str::to_owned)).collect();
assert_eq!(submenus, ["Words"]);
f.host.uninstall("hello").unwrap();
for (version, extra, message) in [
("^1.3", "menu = \"tools\"\nsubmenu = \"Words\"", "only for context menus"),
("^1.3", "menu = \"editor.context\"\nsubmenu = \" \"", "titles are"),
("^1.2", "menu = \"editor.context\"\nsubmenu = \"Words\"", "1.3"),
] {
let dir = tempfile::tempdir().unwrap();
let pkg = hello_variant(dir.path(), with_menu(version, extra));
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{extra}: {}", err.message);
}
}
#[test]
fn view_item_menus_name_a_declared_view() {
/// The menu is new in API 1.2.
fn api_1_2(m: &str) -> String {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.2\"")
}
fn with_menu(extra: &str) -> impl Fn(&str) -> String + '_ {
move |m: &str| format!("{}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n", api_1_2(m))
}
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let second_view =
"[[contributes.views]]\nid = \"more\"\ntitle = \"More\"\nicon = \"plugin\"\nlocation = \"right\"\n";
let ok = hello_variant(tmp.path(), |m| {
let on_greetings =
with_menu("menu = \"viewItem.context\"\nview = \"greetings\"\nwhen = \"viewItemKind == English\"")(m);
let on_more = with_menu("menu = \"viewItem.context\"\nview = \"more\"")("");
format!("{on_greetings}\n{second_view}\n{on_more}")
});
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
let item = f.host.ui_items("menus").into_iter().find(|i| i.decl["menu"] == "viewItem.context").unwrap();
assert_eq!(
(item.key.as_str(), item.decl["view"].as_str()),
("viewItem.context/greetings/word-count", Some("greetings"))
);
let keys: Vec<String> = f.host.ui_items("menus").into_iter().map(|i| i.key).collect();
for view in ["greetings", "more"] {
assert!(keys.contains(&format!("viewItem.context/{view}/word-count")), "{keys:?}");
}
f.host.uninstall("hello").unwrap();
for (extra, message) in [
("menu = \"viewItem.context\"", "needs `view`"),
("menu = \"viewItem.context\"\nview = \"nope\"", "view \"nope\" is not declared"),
("menu = \"projectView.context\"\nview = \"greetings\"", "`view` is only for viewItem.context"),
] {
let dir = tempfile::tempdir().unwrap();
let err = f.host.install_from_dir(&hello_variant(dir.path(), with_menu(extra)), &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{extra}: {}", err.message);
}
let dir = tempfile::tempdir().unwrap();
let web_view = "[[contributes.views]]\nid = \"page\"\ntitle = \"Page\"\nicon = \"plugin\"\nlocation = \"right\"\nweb = \"web/index.html\"\n";
let pkg = hello_variant(dir.path(), |m| {
let on_page = with_menu("menu = \"viewItem.context\"\nview = \"page\"")("");
format!("{}\n{web_view}\n{on_page}", api_1_2(m))
});
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<p>hi</p>").unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains("is a web view"), "{}", err.message);
let dir = tempfile::tempdir().unwrap();
let old = hello_variant(dir.path(), |m| {
format!(
"{m}\n[[contributes.menus]]\nmenu = \"viewItem.context\"\nview = \"greetings\"\ncommand = \"word-count\"\n"
)
});
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.2"), "{}", err.message);
}
#[test]
fn conditional_and_grouped_views_need_api_1_3() {
for field in ["group = \"spring\"", "when = \"workspaceContains:pom.xml || plugin.hello.ready\""] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let old =
hello_variant(tmp.path(), |m| m.replace("location = \"right\"", &format!("location = \"right\"\n{field}")));
let err = f.host.install_from_dir(&old, &Consent::all()).unwrap_err();
assert!(err.message.contains("new in plugin API 1.3"), "{err:?}");
}
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |m| {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"").replace(
"location = \"right\"",
"location = \"right\"\ngroup = \"spring\"\nwhen = \"workspaceContains:pom.xml || plugin.hello.ready\"",
)
});
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Inactive, "declaring views must not activate code");
let view = f.host.ui_items("views").remove(0);
assert_eq!(view.decl["group"], "spring");
assert_eq!(view.decl["when"], "workspaceContains:pom.xml || plugin.hello.ready");
f.host.set_enabled("hello", false).unwrap();
assert!(f.host.ui_items("views").is_empty());
}
#[test]
fn grouped_views_fixture_renders_and_scopes_context_and_reveal() {
let f = fixture(Limits::default());
let info = f.install(&sample("grouped-views"));
assert_eq!(info.status, PluginStatus::Inactive, "views stay lazy");
assert!(info.error.is_none(), "{:?}", info.error);
let views = f.host.ui_items("views");
assert_eq!(views.len(), 3);
assert_eq!(views[0].decl["group"], "demo");
assert_eq!(views[1].decl["when"], "workspaceContains:grouped-views.txt");
f.take_events();
f.run("grouped-views", "show", None).unwrap();
assert!(f.take_events().iter().any(|(client, event)| matches!(
event,
PluginEvent::ContextChanged { plugin_id, key, value }
if client.as_deref() == Some("main") && plugin_id == "grouped-views"
&& key == "ready" && value.as_deref() == Some("true")
)));
assert!(f.host.context_keys(Some("other")).is_empty());
for view in ["overview", "files", "legacy"] {
let rendered = f.host.render_view("grouped-views", view, None, f.inv()).unwrap();
assert!(matches!(rendered.body, ide_plugin::ViewBody::Message(_)));
f.run("grouped-views", "reveal", Some(view)).unwrap();
assert!(f.take_events().iter().any(|(client, event)| matches!(
event,
PluginEvent::RevealView { plugin_id, view: target }
if client.as_deref() == Some("main") && plugin_id == "grouped-views" && target == view
)));
}
assert!(f.run("grouped-views", "reveal", Some("plugin.hello.greetings")).is_err());
f.host.set_enabled("grouped-views", false).unwrap();
assert!(f.host.ui_items("views").is_empty());
assert!(f.host.context_keys(Some("main")).is_empty());
}
#[test]
fn view_conditions_and_group_ids_are_validated() {
for (field, message) in [
("group = \"\"", "invalid view group"),
("group = \"../other\"", "invalid view group"),
("when = \"ready && (\"", "when"),
] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |m| {
m.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
.replace("location = \"right\"", &format!("location = \"right\"\n{field}"))
});
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{field}: {err:?}");
}
}
/// The sample plugin makes a tool window with a tree view.
/// Its menus, shortcut and status bar item are declarations, listed before
/// any code runs; the tree is rendered by the code, which loads on first show.
#[test]
fn sample_plugin_tool_window_with_a_tree() {
let f = fixture(Limits::default());
let info = f.install(&sample("hello"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
for point in ["menus", "keymaps", "statusBar", "views"] {
assert_eq!(f.host.ui_items(point).len(), 1, "{point}");
}
let view = f.host.ui_items("views").remove(0);
assert_eq!((view.key.as_str(), view.decl["location"].as_str()), ("greetings", Some("right")));
assert_eq!(f.status("hello"), PluginStatus::Inactive, "declarations need no code");
let tree = |f: &Fixture| match f.host.render_view("hello", "greetings", None, f.inv()).unwrap().body {
ide_plugin::ViewBody::Tree(items) => items,
other => panic!("{other:?}"),
};
assert_eq!(tree(&f).iter().map(|i| i.label.as_str()).collect::<Vec<_>>(), ["English", "Deutsch", "日本語"]);
assert_eq!(f.status("hello"), PluginStatus::Active, "activated on show (on-view)");
f.take_events();
f.host.view_event("hello", "greetings", ide_plugin::ViewEvent::Expand { key: "English".into() }, f.inv()).unwrap();
let refreshed = f.take_events().into_iter().any(|(_, e)| {
matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "hello" && view == "greetings")
});
assert!(refreshed, "the plugin asked for a refresh");
let items = tree(&f);
assert_eq!(items.len(), 6);
assert_eq!(items[1].parent.as_deref(), Some("English"));
f.host
.view_event("hello", "greetings", ide_plugin::ViewEvent::Activate { key: "English/Hi".into() }, f.inv())
.unwrap();
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::Notification { title, .. } if title == "Hi")));
// Disabling withdraws every declaration.
f.host.set_enabled("hello", false).unwrap();
for point in ["menus", "keymaps", "statusBar", "views"] {
assert!(f.host.ui_items(point).is_empty(), "{point}");
}
}
/// The tables fixture sorts its own rows: a `table` body sent through `other`
/// opts the headers in, and a header click leaves as an `other` view-event
/// (kind `sort`) the plugin decodes and answers with the rows in the asked
/// order (plugins/README.md §10: released types stay, `other` carries new
/// cases).
#[test]
fn sortable_plugin_table_round_trips_sort_events() {
let f = fixture(Limits::default());
let info = f.install(&sample("tables"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let table = |f: &Fixture| match f.host.render_view("tables", "tables", None, f.inv()).unwrap().body {
ide_plugin::ViewBody::Table(t) => t,
other => panic!("{other:?}"),
};
// No sort yet: headers are clickable, rows in the plugin's own order.
let t = table(&f);
assert_eq!((t.columns.clone(), t.total_rows), (vec!["ext".to_owned(), "files".into()], 4));
assert_eq!((t.sortable, t.sort), (true, None), "a `table` through `other` opts the headers in");
assert_eq!(t.rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>(), ["rs", "md", "java", "toml"]);
f.take_events();
let sort = |f: &Fixture, descending| {
f.host.view_event("tables", "tables", ide_plugin::ViewEvent::Sort { column: 1, descending }, f.inv()).unwrap();
assert!(f.take_events().into_iter().any(
|(_, e)| matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "tables" && view == "tables")
));
table(f)
};
// The plugin reports the sort it ordered by, with the rows to match.
let t = sort(&f, false);
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: false }));
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["3", "9", "12", "20"]);
let t = sort(&f, true);
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: true }));
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["20", "12", "9", "3"]);
}
/// Regex Tester: an `html` body its script talks to; the plugin answers in
/// view messages, in UTF-16 offsets.
#[test]
fn official_regex_tester_answers_its_page() {
let f = fixture(Limits::default());
let info = f.install(&official("regex-tester"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let view = f.host.render_view("openidea.regex-tester", "regex", None, f.inv()).unwrap();
let ide_plugin::ViewBody::Html(page) = &view.body else { panic!("{view:?}") };
assert!(page.contains("openidea.postMessage") && page.contains(r#"{"flags":"m"}"#));
f.take_events();
let run = serde_json::json!({ "op": "run", "seq": 3, "pattern": r"(\d+)", "flags": "", "text": "é 12 345" });
let event = ide_plugin::ViewEvent::Message { text: run.to_string() };
f.host.view_event("openidea.regex-tester", "regex", event, f.inv()).unwrap();
let reply = f
.take_events()
.into_iter()
.find_map(|(_, e)| match e {
PluginEvent::ViewMessage { view, message, .. } if view == "regex" => Some(message),
_ => None,
})
.expect("a reply");
let reply: serde_json::Value = serde_json::from_str(&reply).unwrap();
assert_eq!((reply["seq"].clone(), reply["count"].clone()), (serde_json::json!(3), serde_json::json!(2)));
assert_eq!(reply["matches"][0]["groups"][0], serde_json::json!([2, 4]));
}
#[test]
fn settings_actions_choose_their_layer_without_losing_the_project() {
let f = fixture(Limits::default());
f.install(&sample("settings-pages"));
for (global, name) in [(false, "Project"), (true, "Global")] {
let values = serde_json::json!({ "profiles": [{ "name": name, "provider": "local", "baseUrl": "http://localhost", "model": "test", "enabled": true, "temperature": 0.7, "limit": 100 }] });
let layer = if global { &f.workspace.global_settings } else { &f.workspace.settings };
layer.lock().insert("settings-pages".into(), values.as_object().unwrap().clone());
}
for (global_settings, name) in [(false, "Project"), (true, "Global"), (false, "Project")] {
let inv = Invocation { global_settings, ..f.inv() };
let result = f.host.run_command("settings-pages", "test", None, inv).unwrap().unwrap();
let result: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(result["profiles"][0]["name"], name);
assert_eq!(result["hasProject"], true);
}
}
/// A tree filled from a pinned program's output, and a terminal for the
/// activated entry in the invoking window, titled and sized by the plugin's
/// settings (the `jobs` fixture).
#[cfg(unix)]
#[test]
fn pinned_programs_fill_views_and_open_terminals() {
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits::default());
let bin = f.plugins_dir.parent().unwrap().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join("jobs"), "#!/bin/sh\n[ \"$1\" = list ] && printf 'build\\ndeploy\\n'\n").unwrap();
std::fs::set_permissions(bin.join("jobs"), std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone();
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let view = f.host.render_view("jobs", "jobs", None, f.inv()).unwrap();
let ide_plugin::ViewBody::Tree(items) = &view.body else { panic!("{view:?}") };
assert_eq!(items.iter().map(|i| i.key.as_str()).collect::<Vec<_>>(), ["build", "deploy"]);
let open = |key: &str| {
f.take_events();
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Activate { key: key.into() }, f.inv()).unwrap();
f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenTerminal { title, program, args, .. } => Some((client, title, program, args)),
_ => None,
})
};
let (client, title, program, args) = open("deploy").expect("a terminal");
assert_eq!((client.as_deref(), title.as_str()), (Some("main"), "follow: deploy"));
assert_eq!(program.0, bin.join("jobs").canonicalize().unwrap());
assert_eq!(args, ["follow", "deploy", "--lines=20"]);
// The plugin's settings, string and number.
let values = serde_json::json!({ "title": "tail ", "lines": 5 }).as_object().unwrap().clone();
f.workspace.settings.lock().insert("jobs".into(), values);
let (_, title, _, args) = open("build").expect("a terminal");
assert_eq!(title, "tail build");
assert_eq!(args, ["follow", "build", "--lines=5"]);
}
#[test]
fn views_open_project_files_at_a_line() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
let press = |button: &str| {
f.take_events();
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
_ => None,
});
(result, opened)
};
let (result, opened) = press("log");
assert!(result.is_ok(), "{result:?}");
let (client, path, line, column) = opened.expect("a location");
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
let (result, opened) = press("outside");
assert!(result.is_err(), "a path outside the project is refused");
assert!(opened.is_none());
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, opened) = press("log");
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
assert!(opened.is_none());
}
#[test]
fn commands_see_the_paths_they_were_run_on() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
let (a, b) = (f.project.join("src/a.txt"), f.project.join("src/b.txt"));
let inv = Invocation { selected_paths: vec![a.clone(), b.clone()], ..f.inv() };
let out = f.host.run_command("jobs", "selection", Some(&to_ipc(&a)), inv).unwrap();
assert_eq!(out.as_deref(), Some(format!("{}\n{}", to_ipc(&a), to_ipc(&b)).as_str()));
assert_eq!(f.run("jobs", "selection", None).unwrap().as_deref(), Some(""));
}
#[test]
fn vcs_remotes_clipboard_and_open_url() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
// Not a git repository yet: `vcs-remotes` returns an empty list.
assert_eq!(f.run("jobs", "remotes", None).unwrap().as_deref(), Some(""));
git(&f.project, &["init", "-b", "main"]);
git(&f.project, &["remote", "add", "origin", "[email protected]:l0ng-ai/openidea.git"]);
git(&f.project, &["remote", "add", "upstream", "https://github.com/org/openidea"]);
assert_eq!(
f.run("jobs", "remotes", None).unwrap().as_deref(),
Some("[email protected]:l0ng-ai/openidea.git\nupstream=https://github.com/org/openidea")
);
f.host.set_capability("jobs", "vcs.read", false).unwrap();
assert!(f.run("jobs", "remotes", None).unwrap_err().contains("vcs.read"), "needs vcs.read");
// `ui.copy-to-clipboard` requires `clipboard` and addresses the calling window.
f.take_events();
f.run("jobs", "copy", Some("https://github.com/l0ng-ai/openidea")).unwrap();
let copied = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::CopyToClipboard { plugin_id, text } => Some((client, plugin_id, text)),
_ => None,
});
assert_eq!(copied, Some((Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea".into())));
f.host.set_capability("jobs", "clipboard", false).unwrap();
f.take_events();
assert!(f.run("jobs", "copy", Some("secret")).unwrap_err().contains("clipboard"), "needs clipboard");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::CopyToClipboard { .. })));
// `ui.open-url` accepts http(s) URLs and rejects other schemes.
f.take_events();
f.run("jobs", "open-url", Some("https://github.com/l0ng-ai/openidea/blob/main/README.md#L1")).unwrap();
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::OpenUrl { plugin_id, url } => Some((client, plugin_id, url)),
_ => None,
});
assert_eq!(
opened,
Some(
(Some("main".into()), "jobs".into(), "https://github.com/l0ng-ai/openidea/blob/main/README.md#L1".into(),)
)
);
for bad in ["javascript:alert(1)", "file:///etc/passwd", ""] {
f.take_events();
let err = f.run("jobs", "open-url", Some(bad)).unwrap_err();
assert!(err.contains("http:// or https://"), "{bad:?}: {err}");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::OpenUrl { .. })));
}
}
/// Runs `cmd` of the jobs plugin; once it asks the user, answers after `delay`
/// (`None`: dismisses). Returns the command's result and what it asked.
fn run_and_answer(
f: &Fixture,
cmd: &str,
arg: Option<&str>,
answer: Option<&[&str]>,
delay: Duration,
) -> (Result<Option<String>, String>, ide_plugin::Prompt) {
std::thread::scope(|scope| {
let running = scope.spawn(|| f.run("jobs", cmd, arg));
let deadline = Instant::now() + Duration::from_secs(10);
let (client, prompt_id, prompt) = loop {
let asked = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
_ => None,
});
if let Some(asked) = asked {
break asked;
}
assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked");
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command");
std::thread::sleep(delay);
let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect());
assert!(f.host.answer_prompt(&prompt_id, answer));
assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once");
(running.join().unwrap(), prompt)
})
}
#[test]
fn commands_ask_the_user_and_reveal_their_views() {
use ide_plugin::{PickItem, Prompt};
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
let none = Duration::ZERO;
let (result, prompt) = run_and_answer(&f, "pick", None, Some(&["b"]), none);
assert_eq!(result.unwrap().as_deref(), Some("b"));
let items = vec![
PickItem { key: "a".into(), label: "Alpha".into(), description: Some("first".into()) },
PickItem { key: "b".into(), label: "Beta".into(), description: None },
PickItem { key: "c".into(), label: "Gamma".into(), description: None },
];
assert_eq!(prompt, Prompt::QuickPick { title: "Pick".into(), items, multiple: false });
let (result, prompt) = run_and_answer(&f, "pick-many", None, Some(&["a", "c"]), none);
assert_eq!(result.unwrap().as_deref(), Some("a,c"));
assert!(matches!(prompt, Prompt::QuickPick { multiple: true, .. }));
assert_eq!(run_and_answer(&f, "pick", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
assert_eq!(result.unwrap().as_deref(), Some("Other"));
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()), multiline: false });
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "input-multiline", Some("one\ntwo"), Some(&["a\nb"]), none);
assert_eq!(result.unwrap().as_deref(), Some("a\nb"));
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("one\ntwo".into()), multiline: true });
assert_eq!(run_and_answer(&f, "input-multiline", None, None, none).0.unwrap().as_deref(), Some("none"));
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
assert_eq!(result.unwrap().as_deref(), Some("true"));
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
assert_eq!(run_and_answer(&f, "confirm", None, None, none).0.unwrap().as_deref(), Some("false"));
// The wait is not part of the call's 500 ms budget.
let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::from_millis(1200));
assert_eq!(result.unwrap().as_deref(), Some("true"));
// Keys come back as given: one that doesn't fit is refused, not cut.
f.take_events();
let err = f.run("jobs", "pick", Some("long-key")).unwrap_err();
assert!(err.contains("item key is longer"), "{err}");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
// Without a window there is nothing to attach the dialog to.
f.take_events();
let no_window = Invocation { client: None, ..f.inv() };
for cmd in ["pick", "input", "input-multiline", "confirm"] {
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
}
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
// `views.reveal` only knows the plugin's own declared views.
f.take_events();
f.run("jobs", "reveal", Some("jobs")).unwrap();
let revealed = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::RevealView { plugin_id, view } => Some((client, plugin_id, view)),
_ => None,
});
assert_eq!(revealed, Some((Some("main".into()), "jobs".into(), "jobs".into())));
for view in ["greetings", ""] {
let err = f.run("jobs", "reveal", Some(view)).unwrap_err();
assert!(err.contains("declares no view"), "{view:?}: {err}");
}
assert!(f.run("jobs", "reveal", Some("jobs")).is_ok(), "the instance survives");
}
#[test]
fn disabling_a_plugin_that_waits_for_an_answer_does_not_hang() {
let f = fixture(Limits::default());
let info = f.install(&sample("jobs"));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
std::thread::scope(|scope| {
let running = scope.spawn(|| f.run("jobs", "confirm", None));
let deadline = Instant::now() + Duration::from_secs(10);
while !f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })) {
assert!(Instant::now() < deadline && !running.is_finished(), "the command never asked");
std::thread::sleep(Duration::from_millis(10));
}
let started = Instant::now();
f.host.set_enabled("jobs", false).unwrap();
assert!(started.elapsed() < Duration::from_secs(5), "disabling waited for the user");
assert!(running.join().unwrap().is_err(), "the waiting command ends with an error");
});
f.host.set_enabled("jobs", true).unwrap();
let (result, _) = run_and_answer(&f, "confirm", None, Some(&[]), Duration::ZERO);
assert_eq!(result.unwrap().as_deref(), Some("true"), "prompts work again after the restart");
}
#[test]
fn views_can_use_an_svg_icon_from_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
let package = |dir: &str, icon: &str, svg: &str| {
let pkg = tmp.path().join(dir);
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "panel-icon"
name = "Panel Icon"
version = "1.0.0"
engines.openidea = "^1.0"
[[contributes.views]]
id = "panel"
title = "Panel"
icon = "{icon}"
location = "bottom"
web = "panel/index.html"
"#
),
)
.unwrap();
pkg
};
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
let info = f.install(&package("clean", "icons/panel.svg", clean));
assert!(info.error.is_none(), "{:?}", info.error);
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("<rect"));
}
#[test]
fn a_package_ships_translations_in_locales() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let package = |id: &str, catalogs: &[(&str, &str)]| {
let pkg = tmp.path().join(id);
std::fs::create_dir_all(pkg.join("locales")).unwrap();
for (name, text) in catalogs {
std::fs::write(pkg.join("locales").join(name), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
format!(
"id = \"{id}\"\nname = \"Words\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\n\
[[contributes.commands]]\nid = \"count\"\ntitle = \"Count Words\"\n"
),
)
.unwrap();
pkg
};
for bad in ["{", "[]", r#"{"Count Words": 1}"#] {
let pkg = package("bad-catalog", &[("fr.json", bad)]);
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "{bad}");
}
let pkg = package("words", &[("fr.json", r#"{"Count Words": "Compter les mots"}"#), ("zh-CN.json", "{}")]);
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let item = f.host.ui_items("locales").into_iter().find(|i| i.plugin_id == "words").expect("the catalogs");
assert_eq!(item.decl["tags"], serde_json::json!(["fr", "zh-CN"]));
let assets = f.host.assets("words", "locale/fr").unwrap();
assert_eq!(assets.len(), 1);
assert!(assets[0].1.contains("Compter les mots"));
let other = f.install(&package("other", &[]));
assert!(other.error.is_none(), "{:?}", other.error);
assert!(f.host.assets("other", "locale/").unwrap().is_empty());
}
/// A plugin with web views and no code: its pages and their files are
/// installed with it and served only from the declared directories, while
/// it is enabled and only for the installed version; file icons and
/// templates are projected too.
#[test]
fn web_views_serve_only_their_own_files() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("web-demo");
for (path, text) in [
("web/index.html", "<!doctype html><html><head></head><body>draw</body></html>"),
("web/assets/app.js", "console.log(1)"),
("panel/index.html", "<p>panel</p>"),
(
"icons/draw.svg",
r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##,
),
("templates/new.draw", "{\"type\": \"drawing\"}\n"),
("secret.txt", "not served"),
] {
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
std::fs::write(pkg.join(path), text).unwrap();
}
std::fs::write(
pkg.join("plugin.toml"),
r#"id = "web-demo"
name = "Web Demo"
version = "1.0.0"
engines.openidea = "^1.0"
[capabilities]
optional = ["clipboard"]
[[contributes.customEditors]]
id = "draw"
title = "Drawing"
patterns = ["*.draw"]
layout = "preview"
web = "web/index.html"
[[contributes.views]]
id = "panel"
title = "Panel"
web = "panel/index.html"
[[contributes.fileIcons]]
id = "draw"
patterns = ["*.draw"]
icon = "icons/draw.svg"
[[contributes.fileTemplates]]
id = "draw"
name = "Drawing"
extension = "draw"
path = "templates/new.draw"
"#,
)
.unwrap();
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
assert!(info.capabilities.iter().any(|c| c.id == "clipboard" && c.granted), "{:?}", info.capabilities);
// The pages' directories are installed with the plugin; files nothing declares are not.
let installed = f.installed("web-demo");
assert!(installed.join("web/assets/app.js").is_file() && installed.join("panel/index.html").is_file());
assert!(!installed.join("secret.txt").exists());
let page = f.host.web_page("web-demo", "draw").unwrap();
let (rev, entry) = page.path.strip_prefix("web-demo/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let file = |path: &str| f.host.web_file("web-demo", path);
let js = file(&format!("{rev}/web/assets/app.js")).unwrap();
assert_eq!((js.bytes.as_slice(), js.mime), (b"console.log(1)".as_slice(), "text/javascript; charset=utf-8"));
assert!(file(&format!("{rev}/panel/index.html")).is_ok());
assert_eq!(f.host.web_page("web-demo", "panel").unwrap().path, format!("web-demo/{rev}/panel/index.html"));
for denied in [
format!("{rev}/secret.txt"),
format!("{rev}/plugin.toml"),
format!("{rev}/web/../secret.txt"),
format!("{rev}/icons/draw.svg"),
"stale-revision/web/index.html".to_owned(),
] {
assert!(file(&denied).is_err(), "{denied} was served");
}
assert!(f.host.web_page("web-demo", "nope").is_err());
// File icons and templates.
let icon = f.host.assets("web-demo", "fileIcon/draw/").unwrap();
assert_eq!(icon.len(), 1);
assert!(icon[0].0 == "fileIcon/draw/light" && icon[0].1.contains("<rect"));
let templates = f.host.ui_items("fileTemplates");
assert_eq!(templates[0].decl["body"], "{\"type\": \"drawing\"}\n");
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview");
// Disabled: nothing is served.
f.host.set_enabled("web-demo", false).unwrap();
assert!(file(&format!("{rev}/web/index.html")).is_err());
assert!(f.host.web_page("web-demo", "draw").is_err());
}
#[test]
fn file_templates_fixture_installs_without_write_capabilities() {
let f = fixture(Limits::default());
let info = f.install(&sample("file-templates"));
assert!(info.capabilities.is_empty());
let templates = f.host.ui_items("fileTemplates");
assert_eq!(templates.len(), 4);
let template = |id: &str| templates.iter().find(|t| t.decl["id"] == id).unwrap();
assert_eq!(template("dockerignore").decl["fileName"], ".dockerignore");
assert_eq!(template("attributes").decl["variables"].as_array().unwrap().len(), 2);
assert!(template("repository").decl["body"].as_str().unwrap().contains("$PACKAGE$"));
assert!(f.installed("file-templates").join("templates/repository.txt").is_file());
assert_eq!(f.run("file-templates", "ping", None).unwrap().as_deref(), Some("File templates ready"));
f.host.set_enabled("file-templates", false).unwrap();
assert!(f.host.ui_items("fileTemplates").is_empty());
}
#[test]
fn file_templates_api_13_names_and_variables() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("template-demo");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(pkg.join("body.txt"), "$ENTITY$ $PACKAGE$\n").unwrap();
let install = |api: &str, fields: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"id = "template-demo"
name = "Templates"
version = "1.0.0"
engines.openidea = "{api}"
[[contributes.fileTemplates]]
id = "template"
name = "Template"
path = "body.txt"
{fields}
"#
),
)
.unwrap();
f.host.install_from_dir(&pkg, &Consent::all())
};
for fields in [r#"fileName = ".dockerignore""#, "extension = \"java\"\nvariables = []"] {
let err = install("^1.2", fields).unwrap_err().message;
assert!(err.contains("1.3"), "{err}");
}
for name in ["", ".", "..", "../escape", "a/b", r"a\b", "/absolute", "C:evil", "a?", " a", "a.", "NUL.txt", "COM1"]
{
let fields = format!("fileName = {}", serde_json::to_string(name).unwrap());
assert!(install("^1.3", &fields).is_err(), "accepted {name:?}");
}
for vars in [
r#"[{ name = "", prompt = "Entity" }]"#,
r#"[{ name = "1bad", prompt = "Entity" }]"#,
r#"[{ name = "bad-name", prompt = "Entity" }]"#,
r#"[{ name = "PACKAGE", prompt = "Entity" }]"#,
r#"[{ name = "ENTITY", prompt = "" }]"#,
r#"[{ name = "ENTITY", prompt = "Entity" }, { name = "ENTITY", prompt = "Again" }]"#,
] {
assert!(install("^1.3", &format!("extension = \"java\"\nvariables = {vars}")).is_err(), "accepted {vars}");
}
install(
"^1.3",
r#"fileName = ".dockerignore"
variables = [{ name = "ENTITY", prompt = "Entity class" }]"#,
)
.unwrap();
let items = f.host.ui_items("fileTemplates");
assert_eq!(items[0].decl["fileName"], ".dockerignore");
assert_eq!(items[0].decl["extension"], "");
assert_eq!(items[0].decl["variables"][0]["prompt"], "Entity class");
assert_eq!(items[0].decl["body"], "$ENTITY$ $PACKAGE$\n");
}
/// A view needs a page or code to render it; a page's directory can't be the package itself.
#[test]
fn web_view_declarations_are_checked() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("bad-web");
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(pkg.join("index.html"), "x").unwrap();
let manifest = |decl: &str| {
std::fs::write(
pkg.join("plugin.toml"),
format!("id = \"bad-web\"\nname = \"B\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n{decl}"),
)
.unwrap();
f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err().message
};
let err = manifest("[[contributes.customEditors]]\nid = \"e\"\ntitle = \"E\"\npatterns = [\"*.x\"]\n");
assert!(err.contains("give a web page (`web`) or the code"), "{err}");
let err = manifest("[[contributes.views]]\nid = \"v\"\ntitle = \"V\"\nweb = \"index.html\"\n");
assert!(err.contains("directory of its own"), "{err}");
let err = manifest(
"[[contributes.customEditors]]\nid = \"e\"\ntitle = \"E\"\npatterns = [\"*.x\"]\nlayout = \"side\"\nweb = \"w/i.html\"\n",
);
assert!(err.contains("no such file") || err.contains("`layout`"), "{err}");
}
/// Listing plugins (each plugin's state, then its grants) while another
/// window's project changes (the grants, then each plugin's state) must not
/// deadlock: both take the grant ledger first.
#[test]
fn listing_while_projects_change_does_not_deadlock() {
let f = fixture(Limits::default());
f.install(&misbehave_pkg());
f.install(&sample("hello"));
let host = Arc::new(f.host);
let done = Arc::new(std::sync::atomic::AtomicBool::new(false));
let workers: Vec<_> = (0..4)
.map(|i| {
let (host, done) = (host.clone(), done.clone());
let root = f._tmp.path().join(format!("p{i}"));
std::thread::spawn(move || {
while !done.load(std::sync::atomic::Ordering::Relaxed) {
if i % 2 == 0 {
host.list(None);
} else {
host.restrict_project(&root);
}
}
})
})
.collect();
std::thread::sleep(Duration::from_millis(500));
done.store(true, std::sync::atomic::Ordering::Relaxed);
let started = Instant::now();
for w in workers {
while !w.is_finished() {
assert!(started.elapsed() < Duration::from_secs(10), "deadlocked");
std::thread::sleep(Duration::from_millis(10));
}
w.join().unwrap();
}
}
/// Excalidraw: a web custom editor beside the text editor, with its fonts,
/// file icon, template and language — no code.
#[test]
fn official_excalidraw_installs_and_serves_its_page() {
let tmp = tempfile::tempdir().unwrap();
let pkg = with_stand_in_page(&official("excalidraw"), tmp.path());
let f = fixture(Limits::default());
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let page = f.host.web_page("openidea.excalidraw", "canvas").unwrap();
let (rev, entry) = page.path.strip_prefix("openidea.excalidraw/").unwrap().split_once('/').unwrap();
assert_eq!(entry, "web/index.html");
let html = f.host.web_file("openidea.excalidraw", &format!("{rev}/{entry}")).unwrap();
let html = String::from_utf8(html.bytes).unwrap();
// Relative URLs only: the page is served from the package.
let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap();
assert!(script.starts_with("./assets/"), "{script}");
assert!(f.host.web_file("openidea.excalidraw", &format!("{rev}/web/{}", &script[2..])).is_ok());
let font = f.host.web_file("openidea.excalidraw", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap();
assert_eq!(font.mime, "font/woff2");
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw"));
assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2);
}
/// A copy of the official plugin at `src` in `dir`, with a stand-in for its
/// built page (`web/` is built by plugins/build.sh, not checked in; build.sh
/// checks what the build produces): an entry loading a script and a font by
/// relative URL.
fn with_stand_in_page(src: &Path, dir: &Path) -> PathBuf {
let pkg = dir.join(src.file_name().unwrap());
std::fs::create_dir_all(&pkg).unwrap();
for entry in std::fs::read_dir(src).unwrap().flatten() {
let name = entry.file_name();
if ["web", "web-src", "web.sources", "target"].iter().any(|n| name == *n) {
continue;
}
let to = pkg.join(&name);
if entry.path().is_dir() {
copy_dir(&entry.path(), &to)
} else {
std::fs::copy(entry.path(), to).map(|_| ()).unwrap()
}
}
std::fs::create_dir_all(pkg.join("web/assets")).unwrap();
std::fs::create_dir_all(pkg.join("web/fonts/Virgil")).unwrap();
std::fs::write(
pkg.join("web/index.html"),
r#"<!doctype html><html><head><script type="module" src="./assets/index.js"></script></head></html>"#,
)
.unwrap();
std::fs::write(pkg.join("web/assets/index.js"), "export {};").unwrap();
std::fs::write(pkg.join("web/fonts/Virgil/Virgil-Regular.woff2"), b"wOF2").unwrap();
pkg
}
/// Graphviz: a web view with code. What the page reports about the graph
/// becomes the previewed file's problems.
#[test]
fn official_graphviz_turns_its_pages_reports_into_problems() {
let tmp = tempfile::tempdir().unwrap();
let f = fixture(Limits::default());
let info = f.install(&with_stand_in_page(&official("graphviz"), tmp.path()));
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "split");
assert!(f.host.languages().iter().any(|l| l.id.as_str() == "dot"));
assert!(f.host.web_page("openidea.graphviz", "preview").is_ok());
std::fs::write(f.project.join("flow.dot"), "digraph {\n a -> \n}\n").unwrap();
let editor = f.workspace.open("flow.dot", &[]);
let inv = Invocation { editor: Some(editor), ..f.inv() };
let report =
r#"{"op":"problems","items":[{"line":3,"message":"syntax error in line 3 near '}'","level":"error"}]}"#;
let event = ide_plugin::ViewEvent::Message { text: report.into() };
f.host.view_event("openidea.graphviz", "preview", event, inv.clone()).unwrap();
let path = f.project.join("flow.dot");
let problems =
|f: &Fixture| f.workspace.diagnostics.lock().get(&("openidea.graphviz".into(), path.clone())).cloned();
let d = problems(&f).expect("published");
assert_eq!((d.len(), d[0].range.start.line), (1, 2));
let cleared = ide_plugin::ViewEvent::Message { text: r#"{"op":"problems","items":[]}"#.into() };
f.host.view_event("openidea.graphviz", "preview", cleared, inv).unwrap();
assert_eq!(problems(&f).map(|d| d.len()), Some(0));
}
/// Zips `dir` as an archiver does: the folder itself at the top, plus macOS metadata.
fn zip_folder(dir: &Path, out: &Path) {
use std::io::Write;
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
let mut zip = zip::ZipWriter::new(std::fs::File::create(out).unwrap());
let options = zip::write::SimpleFileOptions::default();
let mut stack = vec![(dir.to_path_buf(), name.clone())];
while let Some((real, rel)) = stack.pop() {
for entry in std::fs::read_dir(&real).unwrap().flatten() {
let (path, rel) = (entry.path(), format!("{rel}/{}", entry.file_name().to_string_lossy()));
if path.is_dir() {
stack.push((path, rel));
} else {
zip.start_file(rel, options).unwrap();
zip.write_all(&std::fs::read(&path).unwrap()).unwrap();
}
}
}
zip.start_file(format!("__MACOSX/{name}/._plugin.toml"), options).unwrap();
zip.write_all(b"metadata").unwrap();
zip.finish().unwrap();
}
#[test]
fn a_zipped_plugin_folder_installs_like_the_folder() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let archive = tmp.path().join("language-toy.zip");
zip_folder(&sample("language-toy"), &archive);
let dir = f.host.unpack_package(&archive).unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!(preview.id, "language-toy");
assert!(preview.unsigned);
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
assert_eq!(info.id, "language-toy");
assert_eq!(f.host.languages().len(), 1);
// A zip that isn't an OpenIDEA plugin says so.
let other = tmp.path().join("intellij-plugin.zip");
let mut zip = zip::ZipWriter::new(std::fs::File::create(&other).unwrap());
zip.start_file("my-plugin/lib/my-plugin.jar", zip::write::SimpleFileOptions::default()).unwrap();
zip.finish().unwrap();
let err = f.host.unpack_package(&other).unwrap_err().message;
assert!(err.contains("is not an OpenIDEA plugin"), "{err}");
}
/// What the UI writes for an imported IntelliJ theme goes through the install page.
#[test]
fn a_generated_package_is_staged_for_the_install_page() {
let f = fixture(Limits::default());
let file = |path: &str, text: &str| PackageFile { path: path.into(), text: text.into() };
let manifest = r#"id = "intellij.deep-sea"
name = "Deep Sea"
version = "2.3.0"
engines.openidea = "^1.0"
[[contributes.themes]]
path = "themes/deep-sea.theme.json"
editor-scheme = "themes/deep-sea.scheme.json"
"#;
let files = [
file("plugin.toml", manifest),
file(
"themes/deep-sea.theme.json",
r##"{"id":"deep-sea","name":"Deep Sea","kind":"dark","tokens":{"--bg-app":"#101820"}}"##,
),
file("themes/deep-sea.scheme.json", r##"{"name":"Deep Sea","tokens":{"--syn-keyword":"#cc7832"}}"##),
];
let dir = f.host.stage_package("intellij.deep-sea", &files).unwrap();
let preview = f.host.preview_install(&dir).unwrap();
assert_eq!((preview.id.as_str(), preview.has_code, preview.unsigned), ("intellij.deep-sea", false, true));
assert!(preview.capabilities.is_empty());
f.install(&dir);
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--bg-app"], "#101820");
assert_eq!(active.theme.tokens["--syn-keyword"], "#cc7832");
// Staging the same package again starts from an empty folder; importing it again replaces it.
assert_eq!(f.host.stage_package("intellij.deep-sea", &files[..1]).unwrap(), dir);
assert!(!dir.join("themes").exists());
let mut changed = files.clone();
changed[2] = file("themes/deep-sea.scheme.json", r##"{"tokens":{"--syn-keyword":"#ff8800"}}"##);
f.install(&f.host.stage_package("intellij.deep-sea", &changed).unwrap());
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
assert_eq!(active.theme.tokens["--syn-keyword"], "#ff8800");
for bad in ["../x", "/abs", "a//b", r"a\b", "C:x", ".hidden", "a/./b", ""] {
let err = f.host.stage_package("p", &[file("plugin.toml", manifest), file(bad, "x")]).unwrap_err().message;
assert!(err.contains("invalid package path"), "{bad:?}: {err}");
}
let err = f.host.stage_package("p", &[file("x.json", "{}")]).unwrap_err().message;
assert!(err.contains("needs a plugin.toml"), "{err}");
let err = f.host.stage_package("p", &[file("plugin.toml", ""), file("PLUGIN.toml", "")]).unwrap_err().message;
assert!(err.contains("twice"), "{err}");
assert!(f.host.stage_package("../p", &files).is_err());
}
/// Secrets live outside the plugin's directory (the OS keychain on the
/// desktop): uninstalling the plugin deletes the ones it declares.
#[test]
fn uninstalling_deletes_the_plugins_secrets() {
#[derive(Default)]
struct Keychain(Mutex<std::collections::HashMap<(String, String), String>>);
impl SecretStore for Keychain {
fn get(&self, plugin: &str, name: &str) -> Option<String> {
self.0.lock().get(&(plugin.into(), name.into())).cloned()
}
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
let key = (plugin.to_owned(), name.to_owned());
match value {
Some(v) => self.0.lock().insert(key, v.into()),
None => self.0.lock().remove(&key),
};
Ok(())
}
}
let f = fixture(Limits::default());
let keychain = Arc::new(Keychain::default());
f.host.set_secret_store(keychain.clone());
f.install(&official("ai-completion"));
f.host.set_secret("openidea.ai-completion", "api-key", Some("sk-test")).unwrap();
assert!(keychain.get("openidea.ai-completion", "api-key").is_some());
f.host.uninstall("openidea.ai-completion").unwrap();
assert!(keychain.0.lock().is_empty(), "{:?}", keychain.0.lock().keys().collect::<Vec<_>>());
}
/// Plugin API 1.0 leaves room to grow: event kinds are names, and the editor
/// providers carry snippets, imports, lazily computed quick fixes and tagged
/// problems.
#[test]
fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
let f = fixture(Limits::default());
let pkg = f._tmp.path().join("misbehave-providers");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"),
)
.unwrap();
f.install(&pkg);
let doc = |text: &str| ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: text.into(),
};
let at = ide_core::Pos::new(0, 0);
// The first call starts the instance, which may take longer than the edit-path budget.
let mut items = Vec::new();
assert!(eventually(|| {
items = f.host.provide_completion(&doc("rich"), at, None);
!items.is_empty()
}));
let item = &items[0].1;
assert_eq!(
(item.insert_text.as_str(), item.snippet, item.filter_text.as_deref()),
("f(${1:x})$0", true, Some("fun"))
);
assert_eq!(item.additional_edits[0].new_text, "use f;\n");
// Offered without edits; computed when picked.
let actions = f.host.provide_code_actions(&doc("lazy"), ide_core::Range::default());
let (plugin, action) = actions.into_iter().next().expect("offered");
assert!(action.edits.is_empty() && action.data.as_deref() == Some("fix"));
let resolved = f.host.resolve_code_action(&plugin, &doc("lazy"), action.clone(), f.inv()).unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, "fixed ");
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign, f.inv()).is_err());
// File operations come from the `file-actions` export, next to the providers' own.
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
let (_, action) = actions.next().expect("offered");
let (plugin, later) = actions.next().expect("a lazy one too");
let dir = f.project.clone();
assert_eq!(action.title, "Reorganise");
assert_eq!(
action.operations,
vec![
ide_plugin::FileOperation::Create { path: dir.join("created.txt"), text: "hello".into(), overwrite: false },
ide_plugin::FileOperation::Rename { from: dir.join("old.txt"), to: dir.join("renamed.txt") },
ide_plugin::FileOperation::Delete { path: dir.join("gone.txt") },
]
);
assert_eq!(action.open, Some(dir.join("created.txt")));
// Listed with `data` only: `resolve` on the same export computes its operations.
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later, f.inv()).unwrap();
assert_eq!(
resolved.operations,
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
);
assert!(f.host.provide_code_actions(&doc("rich"), ide_core::Range::default()).is_empty());
// A kind this IDE doesn't know is refused, not a trap.
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
assert!(err.message.contains("no event kind"), "{}", err.message);
let err = f.host.run_command("misbehave", "subscribe", Some("file-saved"), f.inv()).unwrap_err();
assert!(err.message.contains("events"), "known, but not granted: {}", err.message);
// Tags the IDE knows, the tool that found it.
let path = f.project.join("a.txt");
f.host.run_command("misbehave", "diagnose", Some(&path.to_string_lossy()), f.inv()).unwrap();
let d = f.workspace.diagnostics.lock().get(&("misbehave".into(), path)).cloned().unwrap();
assert_eq!((d[0].unnecessary, d[0].deprecated, d[0].source.as_deref()), (true, false, Some("lint")));
}
fn interactive_actions_fixture() -> (Fixture, ide_plugin::ProviderDocument) {
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
let pkg = f._tmp.path().join("interactive-actions");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml"))
.unwrap()
.replace("required = [\"project.read\"]", "required = [\"project.read\", \"clipboard\"]");
std::fs::write(pkg.join("plugin.toml"), format!(
"{manifest}\n[[contributes.providers]]\nkinds = [\"completion\", \"code-actions\"]\nlanguages = [\"text\"]\n"
)).unwrap();
f.install(&pkg);
f.host.run_command("misbehave", "echo", None, f.inv()).unwrap();
let doc = ide_plugin::ProviderDocument {
path: f.project.join("a.txt"),
language: "text".into(),
version: 1,
text: "prompts".into(),
};
(f, doc)
}
fn action_prompt(f: &Fixture) -> (String, ide_plugin::Prompt) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some((client, id, prompt)) = f.take_events().into_iter().find_map(|(client, event)| match event {
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
_ => None,
}) {
assert_eq!(client.as_deref(), Some("editor-window"));
return (id, prompt);
}
assert!(Instant::now() < deadline, "code-action resolution never asked");
std::thread::sleep(Duration::from_millis(10));
}
}
fn action_invocation() -> Invocation {
Invocation { client: Some("editor-window".into()), ..Default::default() }
}
#[test]
fn code_action_resolution_prompts_in_the_editor_window_under_the_command_budget() {
use ide_plugin::Prompt;
let (f, doc) = interactive_actions_fixture();
let actions = f.host.provide_code_actions(&doc, ide_core::Range::default());
assert_eq!(actions.len(), 10);
for (kind, answer, expected) in [
("pick", vec!["b"], "b"),
("pick-many", vec!["a", "b"], "a,b"),
("input", vec!["Service😀"], "Service😀"),
("confirm", vec![], "confirmed"),
] {
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, prompt) = action_prompt(&f);
assert!(match kind {
"pick" => matches!(prompt, Prompt::QuickPick { multiple: false, .. }),
"pick-many" => matches!(prompt, Prompt::QuickPick { multiple: true, .. }),
"input" => matches!(prompt, Prompt::Input { multiline: false, .. }),
_ => matches!(prompt, Prompt::Confirm { .. }),
});
// More than the command budget; waiting for the user is excluded.
std::thread::sleep(Duration::from_millis(650));
assert!(f.host.answer_prompt(&id, Some(answer.iter().map(|s| (*s).into()).collect())));
let resolved = running.join().unwrap().unwrap();
assert_eq!(resolved.edits[0].1[0].new_text, expected);
});
}
for kind in ["input-multiline", "reveal", "open-url", "clipboard", "pick-directory", "open-project"] {
let action = actions.iter().find(|(_, a)| a.data.as_deref() == Some(kind)).unwrap().1.clone();
let err = std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let mut prompted = false;
while !running.is_finished() {
for (_, event) in f.take_events() {
if let PluginEvent::PromptRequested { prompt_id, .. } = event {
prompted = true;
f.host.answer_prompt(&prompt_id, None);
}
}
std::thread::sleep(Duration::from_millis(10));
}
let result = running.join().unwrap();
assert!(!prompted, "{kind} must remain restricted to commands and events");
result.unwrap_err()
});
assert_eq!(err.kind, ErrorKind::InvalidInput, "{kind}: {}", err.message);
}
let doc = ide_plugin::ProviderDocument { text: "prompt-provider".into(), ..doc };
assert!(eventually(|| !f.host.provide_completion(&doc, ide_core::Pos::new(0, 0), None).is_empty()));
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
}
#[test]
fn code_action_prompts_dismiss_cancel_and_close_without_edits_or_hanging() {
for (how, kind) in [
("dismiss", "pick"),
("dismiss", "pick-many"),
("dismiss", "input"),
("dismiss", "confirm"),
("disable", "pick"),
("close", "pick"),
("close-reopen", "pick"),
("no-window", "pick"),
] {
let (f, doc) = interactive_actions_fixture();
let action = f
.host
.provide_code_actions(&doc, ide_core::Range::default())
.into_iter()
.find(|(_, action)| action.data.as_deref() == Some(kind))
.unwrap()
.1;
if how == "no-window" {
assert!(f.host.resolve_code_action("misbehave", &doc, action, Invocation::default()).is_err());
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::PromptRequested { .. })));
continue;
}
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
let started = Instant::now();
match how {
"dismiss" => {
assert!(f.host.answer_prompt(&id, None));
}
"disable" => {
f.host.set_enabled("misbehave", false).unwrap();
}
"close" => f.host.forget_client("editor-window"),
"close-reopen" => {
f.host.forget_client("editor-window");
f.host.reopen_client("editor-window");
}
_ => unreachable!(),
}
assert!(running.join().unwrap().is_err());
assert!(started.elapsed() < Duration::from_secs(5));
assert!(!f.host.answer_prompt(&id, Some(vec!["late".into()])));
});
assert!(!f.project.join("a.txt").exists(), "resolution must only return edits");
if how == "close-reopen" {
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
std::thread::scope(|scope| {
let running =
scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
assert!(f.host.answer_prompt(&id, Some(vec!["b".into()])));
assert_eq!(running.join().unwrap().unwrap().edits[0].1[0].new_text, "b");
});
}
}
}
#[test]
fn file_action_resolution_can_ask_before_returning_resource_operations() {
let (f, doc) = interactive_actions_fixture();
let doc = ide_plugin::ProviderDocument { text: "prompt-files".into(), ..doc };
let action = f.host.provide_code_actions(&doc, ide_core::Range::default()).remove(0).1;
assert!(action.file_action && action.operations.is_empty());
std::thread::scope(|scope| {
let running = scope.spawn(|| f.host.resolve_code_action("misbehave", &doc, action, action_invocation()));
let (id, _) = action_prompt(&f);
assert!(f.host.answer_prompt(&id, Some(vec!["Service".into()])));
let resolved = running.join().unwrap().unwrap();
assert_eq!(
resolved.operations,
vec![ide_plugin::FileOperation::Create {
path: f.project.join("Service.txt"),
text: "Service".into(),
overwrite: false,
}]
);
assert_eq!(resolved.open, Some(f.project.join("Service.txt")));
assert!(!f.project.join("Service.txt").exists(), "resolution does not apply the edit");
});
}
/// `hello` as a services view, which needs API 1.3.
fn services_variant(text: &str) -> String {
text.replace("location = \"right\"", "location = \"services\"")
.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
}
#[test]
fn services_views_need_api_1_3_and_code() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let info = f.install(&hello_variant(tmp.path(), services_variant));
assert!(info.error.is_none(), "{:?}", info.error);
assert_eq!(f.host.ui_items("views").remove(0).decl["location"].as_str(), Some("services"));
for (edit, message) in [
(
Box::new(|t: &str| t.replace("location = \"right\"", "location = \"services\""))
as Box<dyn Fn(&str) -> String>,
"new in plugin API 1.3",
),
(
Box::new(|t: &str| t.replace("location = \"right\"", "location = \"middle\"")),
"left, right, bottom or services",
),
(
Box::new(|t: &str| {
services_variant(t)
.replace("location = \"services\"", "location = \"services\"\nweb = \"web/index.html\"")
}),
"a services view is rendered by the plugin's code (no `web`)",
),
] {
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), edit);
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<p>x</p>").unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(err.message.contains(message), "{message}: {err:?}");
}
}
#[test]
fn services_views_have_a_detail_pane() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
f.install(&hello_variant(tmp.path(), services_variant));
let tabs = f.host.service_detail("hello", "greetings", "pod:default/nginx", f.inv()).unwrap();
assert_eq!(tabs.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["log", "info"]);
assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: pod:default/nginx".into()));
let ide_plugin::ServicePane::Log(log) = &tabs[0].body else { panic!("a log: {:?}", tabs[0].body) };
assert_eq!(
(log.program.as_str(), log.args.as_slice()),
("sh", ["-c".to_owned(), "pod:default/nginx".to_owned()].as_slice())
);
let f = fixture(Limits::default());
f.install(&sample("hello"));
let err = f.host.service_detail("hello", "greetings", "x", f.inv()).unwrap_err();
assert!(err.message.contains("declares no detail view"), "a right view has no pane: {err:?}");
}
#[test]
fn detail_views_work_in_every_location_and_require_api_1_3_code() {
for location in ["left", "right", "bottom", "services"] {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |t| {
t.replace("engines.openidea = \"^1.0\"", "engines.openidea = \"^1.3\"")
.replace("location = \"right\"", &format!("location = {location:?}\ndetail = true"))
});
assert!(f.install(&pkg).error.is_none());
assert_eq!(f.host.ui_items("views")[0].decl["detail"], true);
let tabs = f.host.service_detail("hello", "greetings", "item:😀", f.inv()).unwrap();
assert_eq!(tabs[1].body, ide_plugin::ServicePane::Text("greetings: item:😀".into()));
assert!(f.host.service_detail("hello", "missing", "x", f.inv()).is_err());
}
for (api, web, message) in
[("^1.0", false, "new in plugin API 1.3"), ("^1.3", true, "`detail` needs a tree or list")]
{
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |t| {
t.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = {api:?}")).replace(
"location = \"right\"",
if web {
"location = \"right\"\ndetail = true\nweb = \"web/index.html\""
} else {
"location = \"right\"\ndetail = true"
},
)
});
if web {
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<p>x</p>").unwrap();
}
let error = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
assert!(error.message.contains(message), "{error:?}");
}
}
#[test]
fn detail_fixture_tracks_selection_and_refresh_for_tree_list_and_services() {
let f = fixture(Limits::default());
assert!(f.install(&sample("detail-views")).error.is_none());
for view in ["tree", "list", "services"] {
assert!(matches!(
f.host.render_view("test.detail-views", view, None, f.inv()).unwrap().body,
ide_plugin::ViewBody::Tree(_) | ide_plugin::ViewBody::List(_)
));
f.host
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: "beta".into() }, f.inv())
.unwrap();
let tabs = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap();
let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") };
assert!(text.starts_with(&format!("{view}: beta")));
f.host
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Button { id: "refresh".into() }, f.inv())
.unwrap();
let refreshed = f.host.service_detail("test.detail-views", view, "beta", f.inv()).unwrap();
assert_ne!(tabs, refreshed);
}
let selections = [("tree", "alpha"), ("list", "beta"), ("services", "alpha")];
for (view, key) in selections {
f.host
.view_event("test.detail-views", view, ide_plugin::ViewEvent::Select { key: key.into() }, f.inv())
.unwrap();
}
for (view, key) in selections {
let tabs = f.host.service_detail("test.detail-views", view, key, f.inv()).unwrap();
let ide_plugin::ServicePane::Text(text) = &tabs[0].body else { panic!("text detail") };
assert!(text.starts_with(&format!("{view}: {key}")));
}
}
/// `hello` as a services view that may run `sh`, granted or not.
#[cfg(unix)]
fn services_with_sh(f: &Fixture, dir: &Path, grant: bool) {
let sh = PathBuf::from("/bin");
f.host.set_program_path(Arc::new(move || Some(sh.clone().into_os_string())));
let pkg = hello_variant(dir, |t| {
services_variant(t).replace(
"required = [\"editor.read\", \"editor.write\"]",
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]",
)
});
f.install(&pkg);
f.host.set_capability("hello", "process:sh", grant).unwrap();
}
/// Follows the Log tab of the node whose key is `script`.
#[cfg(unix)]
fn stream(
f: &Fixture,
script: &str,
on: &[&str],
cancel: &ide_core::CancellationToken,
lines: impl Fn(PluginOutputLine),
) -> ide_core::IdeResult<Option<i32>> {
let on: Vec<String> = on.iter().map(|s| (*s).to_owned()).collect();
f.host.stream_service_log("hello", "greetings", script, "log", &on, f.inv(), cancel, &lines)
}
#[cfg(unix)]
#[test]
fn service_logs_stream_lines_with_the_toggles_args() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), true);
let lines = Mutex::new(Vec::new());
let script = "printf 'a\\nb\\n'; printf 'e\\n' >&2; exit 3";
let status = stream(&f, script, &[], &Default::default(), |l| lines.lock().push(l));
assert_eq!(status.unwrap(), Some(3));
let mut lines = lines.into_inner();
lines.sort_by_key(|l| l.stderr);
let line = |stderr: bool, text: &str| PluginOutputLine { stderr, text: text.into() };
assert_eq!(lines, [line(false, "a"), line(false, "b"), line(true, "e")]);
// `sh -c script arg` names the script `arg` ($0): the toggle's args are appended.
let named = Mutex::new(Vec::new());
stream(&f, "echo $0", &["named"], &Default::default(), |l| named.lock().push(l.text)).unwrap();
assert_eq!(named.into_inner(), ["named"]);
let long = Mutex::new(Vec::new());
stream(&f, "head -c 40000 /dev/zero | tr '\\0' x; echo; echo next", &[], &Default::default(), |l| {
long.lock().push(l.text.len())
})
.unwrap();
assert_eq!(long.into_inner(), [16 << 10, 4], "a long line is cut, the next one is whole");
}
#[cfg(unix)]
#[test]
fn service_logs_need_the_grant_and_a_log_tab() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), false);
let marker = tmp.path().join("ran");
let script = format!("touch {}", marker.display());
let err = stream(&f, &script, &[], &Default::default(), |_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied);
assert!(err.message.contains("permission denied: process:sh is not granted"), "{err:?}");
assert!(!marker.exists(), "nothing was started");
f.host.set_capability("hello", "process:sh", true).unwrap();
let info =
f.host.stream_service_log("hello", "greetings", &script, "info", &[], f.inv(), &Default::default(), &|_| {});
assert_eq!(info.unwrap_err().kind, ErrorKind::NotFound, "a text tab runs nothing");
let cancelled = ide_core::CancellationToken::default();
cancelled.cancel();
assert_eq!(stream(&f, &script, &[], &cancelled, |_| {}).unwrap_err().kind, ErrorKind::Cancelled);
assert!(!marker.exists(), "a cancelled request starts nothing");
}
#[cfg(unix)]
#[test]
fn ordinary_detail_views_reuse_log_grants_and_streaming() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = hello_variant(tmp.path(), |t| {
services_variant(t).replace("location = \"services\"", "location = \"right\"\ndetail = true").replace(
"required = [\"editor.read\", \"editor.write\"]",
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"process:sh\"]",
)
});
f.install(&pkg);
f.host.set_capability("hello", "process:sh", false).unwrap();
assert_eq!(
stream(&f, "echo allowed", &[], &Default::default(), |_| {}).unwrap_err().kind,
ErrorKind::PermissionDenied
);
f.host.set_capability("hello", "process:sh", true).unwrap();
f.host.set_program_path(Arc::new(|| Some(PathBuf::from("/bin").into_os_string())));
let lines = Mutex::new(Vec::new());
assert_eq!(stream(&f, "echo allowed", &[], &Default::default(), |l| lines.lock().push(l.text)).unwrap(), Some(0));
assert_eq!(lines.into_inner(), ["allowed"]);
}
#[cfg(unix)]
#[test]
fn service_log_cancel_kills_the_child() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), true);
let cancel = ide_core::CancellationToken::default();
let started = Instant::now();
let result = stream(&f, "echo up; sleep 30.4321", &[], &cancel, |l| {
if l.text == "up" {
cancel.cancel();
}
});
assert!(started.elapsed() < Duration::from_secs(2), "{:?}", started.elapsed());
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
let orphan = || std::process::Command::new("pgrep").args(["-f", "sleep 30.4321"]).status().unwrap().success();
assert!(eventually(|| !orphan()), "what the program started is killed too");
}
#[cfg(unix)]
#[test]
fn service_log_stops_when_the_grant_is_revoked() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
services_with_sh(&f, tmp.path(), true);
let started = Instant::now();
let watchdog = ide_core::CancellationToken::default();
let timer = watchdog.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(3));
timer.cancel();
});
let result = stream(&f, "while :; do echo tick; sleep 0.05; done", &[], &watchdog, |l| {
if l.text == "tick" && started.elapsed() < Duration::from_secs(1) {
f.host.set_capability("hello", "process:sh", false).unwrap();
}
});
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
let err = result.unwrap_err();
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{err:?}");
}
#[test]
fn plugins_set_their_own_context_keys() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.take_events();
f.run("jobs", "context", Some("connected=true")).unwrap();
let changed: Vec<_> = f
.take_events()
.into_iter()
.filter_map(|(client, e)| match e {
PluginEvent::ContextChanged { plugin_id, key, value } => Some((client, plugin_id, key, value)),
_ => None,
})
.collect();
assert_eq!(changed, [(Some("main".into()), "jobs".into(), "connected".into(), Some("true".into()))]);
assert_eq!(f.host.context_keys(Some("main")).len(), 1);
assert!(f.host.context_keys(Some("other")).is_empty(), "window-scoped");
for bad in ["bad key=true", "a.b=true", "x=NaN"] {
assert!(f.run("jobs", "context", Some(bad)).unwrap_err().contains("invalid"), "{bad}");
}
f.run("jobs", "context", Some("connected=")).unwrap();
assert!(f.host.context_keys(Some("main")).is_empty());
f.run("jobs", "context", Some("connected=true")).unwrap();
f.host.set_enabled("jobs", false).unwrap();
assert!(f.host.context_keys(Some("main")).is_empty(), "cleared when the plugin stops");
}
#[test]
fn context_keys_are_capped_and_windows_overlay_global_ones() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
for i in 0..100 {
f.run("jobs", "context", Some(&format!("k{i}=1"))).unwrap();
}
assert!(f.run("jobs", "context", Some("k100=1")).unwrap_err().contains("invalid"));
f.run("jobs", "context", Some("k0=2")).unwrap();
for i in 1..4 {
f.run("jobs", "context", Some(&format!("k{i}="))).unwrap();
}
let global = Invocation { client: None, ..f.inv() };
f.host.run_command("jobs", "context", Some("g=1"), global.clone()).unwrap();
f.host.run_command("jobs", "context", Some("w=global"), global).unwrap();
f.run("jobs", "context", Some("w=window")).unwrap();
let w = |client| f.host.context_keys(Some(client)).into_iter().find(|k| k.key == "w").map(|k| k.value);
assert_eq!(w("main").as_deref(), Some("\"window\""));
assert_eq!(w("other").as_deref(), Some("\"global\""));
f.run("jobs", "context", Some("w=")).unwrap();
assert_eq!(w("main").as_deref(), Some("\"global\""), "removing the window key reveals the global one");
}
#[test]
fn context_values_are_stored_as_json_and_bounded() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
let value = |arg: &str| {
f.run("jobs", "context", Some(arg)).unwrap();
f.host.context_keys(Some("main")).into_iter().find(|k| k.key == "x").unwrap().value
};
assert_eq!(value("x=true"), "true");
assert_eq!(value("x=2.5"), "2.5");
assert_eq!(value("x=say \"hi\""), "\"say \\\"hi\\\"\"");
assert_eq!(value("x=1"), "1");
f.take_events();
f.run("jobs", "context", Some("x=1.0")).unwrap();
assert!(f.take_events().is_empty(), "the same value emits nothing");
let long = format!("x={}", "a".repeat(1025));
assert!(f.run("jobs", "context", Some(&long)).unwrap_err().contains("invalid"));
}
#[test]
fn forgetting_a_window_drops_its_context_keys_silently() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.run("jobs", "context", Some("w=1")).unwrap();
f.take_events();
f.host.forget_client("main");
assert!(f.host.context_keys(Some("main")).is_empty());
assert!(f.take_events().is_empty());
}
#[test]
fn a_closed_window_ignores_late_context_writes_until_its_label_is_reused() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.host.forget_client("main");
f.take_events();
f.run("jobs", "context", Some("late=1")).unwrap();
assert!(f.host.context_keys(Some("main")).is_empty());
let events = f.take_events();
assert!(
!events.iter().any(|(_, e)| matches!(e, PluginEvent::ContextChanged { .. })),
"no context event: {events:?}"
);
f.host.reopen_client("main");
f.run("jobs", "context", Some("fresh=1")).unwrap();
assert_eq!(f.host.context_keys(Some("main"))[0].key, "fresh");
}
#[test]
fn stopping_a_plugin_withdraws_each_context_key_in_its_own_scope() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
let global = Invocation { client: None, ..f.inv() };
f.host.run_command("jobs", "context", Some("g=1"), global).unwrap();
f.run("jobs", "context", Some("w=1")).unwrap();
f.take_events();
f.host.set_enabled("jobs", false).unwrap();
let mut removed: Vec<_> = f
.take_events()
.into_iter()
.filter_map(|(client, e)| match e {
PluginEvent::ContextChanged { key, value: None, .. } => Some((client, key)),
_ => None,
})
.collect();
removed.sort();
assert_eq!(removed, [(None, "g".to_owned()), (Some("main".to_owned()), "w".to_owned())]);
}
/// A data-only package `id` in `dir` whose manifest ends with `extra`, plus `files`.
fn icon_package(dir: &Path, id: &str, extra: &str, files: &[(&str, &str)]) -> PathBuf {
let pkg = dir.join(id);
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("id = \"{id}\"\nname = \"Iconic\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.2\"\n{extra}"),
)
.unwrap();
for (name, text) in files {
std::fs::write(pkg.join(name), text).unwrap();
}
pkg
}
const RECT: &str = r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
const CIRCLE: &str = r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="8"/></svg>"#;
#[test]
fn plugin_icons_install_with_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = icon_package(
tmp.path(),
"iconic",
"icon = \"icon.svg\"\nicon-dark = \"icon_dark.svg\"\n",
&[("icon.svg", RECT), ("icon_dark.svg", CIRCLE)],
);
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
let icon = info.icon.expect("icon");
assert!(icon.light.contains("<rect"), "{}", icon.light);
assert!(icon.dark.unwrap().contains("<circle"));
assert!(f.installed("iconic").join("icon_dark.svg").is_file());
f.host.set_enabled("iconic", false).unwrap();
assert!(f.host.info_of("iconic").unwrap().icon.is_some(), "disabled plugins keep their icon");
}
#[test]
fn invalid_plugin_icons_refuse_the_package() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let big = format!("<svg>{}</svg>", "<path d=\"M0 0h1v1z\"/>".repeat(1000));
let padded = format!("<svg><!--{}--><rect/></svg>", " ".repeat(20 << 10));
let cases = [
("scripted", "icon = \"icon.svg\"\n", "<svg><script>alert(1)</script></svg>".to_owned(), "icon.svg"),
("oversized", "icon = \"icon.svg\"\n", big, "16 KiB"),
("padded", "icon = \"icon.svg\"\n", padded, "16 KiB"),
("missing", "icon = \"missing.svg\"\n", RECT.to_owned(), "missing.svg"),
];
for (id, extra, svg, expected) in cases {
let pkg = icon_package(tmp.path(), id, extra, &[("icon.svg", &svg)]);
let err = f.host.preview_install(&pkg).map(|_| ()).unwrap_err().message;
assert!(err.contains(expected), "{id}: {err}");
}
}
#[test]
fn endpoint_exports_refresh_matching_files_and_follow_plugin_lifecycle() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(50)), ..Limits::default() });
let routes = f.project.join("routes.txt");
std::fs::write(&routes, "/users/{id}\n").unwrap();
f.install(&sample("endpoints"));
let id = "test.endpoints";
let paths = || f.host.endpoints(id, &f.project).unwrap().iter().map(|e| e.path.clone()).collect::<Vec<_>>();
let first = f.host.endpoints(id, &f.project).unwrap();
assert_eq!(first[0].path, "/users/{id}");
assert!(first[0].method.is_none(), "any method is preserved");
assert_eq!(first[0].location.path, ide_core::IpcPath::from(to_ipc(&routes)));
std::thread::sleep(Duration::from_millis(70));
f.host.tick();
assert_eq!(f.host.instances(), 0);
assert_eq!(paths(), ["/users/{id}"]);
assert_eq!(f.host.instances(), 0, "a cached list doesn't wake the plugin");
let changed = |f: &Fixture| {
f.take_events()
.iter()
.any(|(_, e)| matches!(e, PluginEvent::EndpointsChanged { plugin_id, .. } if plugin_id == id))
};
std::fs::write(&routes, "/updated\n").unwrap();
f.take_events();
f.host.invalidate_endpoints(&f.project, &[f.project.join("unrelated.txt")]);
assert!(!changed(&f));
assert_eq!(paths(), ["/users/{id}"]);
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
assert!(changed(&f), "windows are told to ask again");
assert_eq!(paths(), ["/updated"]);
std::fs::write(&routes, "not-a-path\n").unwrap();
f.host.invalidate_endpoints(&f.project, std::slice::from_ref(&routes));
assert_eq!(paths(), ["/updated"], "an invalid list doesn't replace the last valid one");
f.host.set_enabled(id, false).unwrap();
assert!(paths().is_empty());
std::fs::write(&routes, "/enabled\n").unwrap();
f.host.set_enabled(id, true).unwrap();
assert_eq!(paths(), ["/enabled"]);
f.take_events();
assert!(f.run(id, "crash", None).is_err());
assert!(changed(&f), "a crash withdraws the routes");
f.host.uninstall(id).unwrap();
assert!(f.host.endpoints(id, &f.project).is_err());
}
struct EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize,
inner: Arc<TestWorkspace>,
roots: Mutex<Vec<PathBuf>>,
entered: Mutex<Option<std::sync::mpsc::Sender<()>>>,
release: Mutex<std::sync::mpsc::Receiver<()>>,
}
impl PluginWorkspace for EndpointWorkspace {
fn projects(&self) -> Vec<PathBuf> {
self.roots.lock().clone()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
if self.reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0
&& let Some(entered) = self.entered.lock().take()
{
entered.send(()).unwrap();
self.release.lock().recv().unwrap();
}
self.inner.buffer_text(path)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
self.inner.document(path)
}
fn trusted(&self, root: &Path) -> bool {
self.roots.lock().iter().any(|r| r == root)
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
self.roots.lock().first().cloned()
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[ide_core::TextEdit]) -> Result<u64, HostError> {
self.inner.apply_edits(path, version, edits)
}
fn publish_diagnostics(
&self,
source: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
self.inner.publish_diagnostics(source, path, items)
}
}
#[test]
fn late_endpoint_results_cannot_restore_a_disabled_plugin_or_closed_project() {
for close_project in [false, true] {
let f = fixture(Limits::default());
std::fs::write(f.project.join("routes.txt"), "/late\n").unwrap();
f.install(&sample("endpoints"));
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let workspace = Arc::new(EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize::new(0),
inner: f.workspace.clone(),
roots: Mutex::new(vec![f.project.clone()]),
entered: Mutex::new(Some(entered_tx)),
release: Mutex::new(release_rx),
});
f.host.set_workspace(Some(workspace.clone()));
let late = std::thread::scope(|scope| {
// The plugin reads the file, then the host's check reads it again and waits here.
let query = scope.spawn(|| f.host.endpoints("test.endpoints", &f.project).unwrap());
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
if close_project {
workspace.roots.lock().clear();
} else {
f.host.set_enabled("test.endpoints", false).unwrap();
}
release_tx.send(()).unwrap();
query.join().unwrap()
});
assert!(late.is_empty());
assert!(f.host.endpoints("test.endpoints", &f.project).unwrap().is_empty());
}
}
#[test]
fn endpoint_results_are_isolated_per_root_and_withdraw_closed_roots() {
let f = fixture(Limits::default());
let other = f.project.parent().unwrap().join("other");
std::fs::create_dir(&other).unwrap();
std::fs::write(f.project.join("routes.txt"), "/first\n").unwrap();
std::fs::write(other.join("routes.txt"), "/second\n").unwrap();
let (_, release_rx) = std::sync::mpsc::channel();
let workspace = Arc::new(EndpointWorkspace {
reads: std::sync::atomic::AtomicUsize::new(0),
inner: f.workspace.clone(),
roots: Mutex::new(vec![f.project.clone(), other.clone()]),
entered: Mutex::new(None),
release: Mutex::new(release_rx),
});
f.host.set_workspace(Some(workspace.clone()));
f.install(&sample("endpoints"));
for (root, route) in [(&f.project, "/first"), (&other, "/second")] {
let list = f.host.endpoints("test.endpoints", root).unwrap();
assert_eq!(list.iter().map(|e| e.path.as_str()).collect::<Vec<_>>(), [route]);
}
workspace.roots.lock().retain(|r| r != &other);
assert!(f.host.endpoints("test.endpoints", &other).unwrap().is_empty(), "a closed project has no routes");
assert_eq!(f.host.endpoints("test.endpoints", &f.project).unwrap()[0].path, "/first");
}
#[test]
fn notification_actions_keep_commands_and_window_scope() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.take_events();
f.host
.run_command("jobs", "notify-actions", None, Invocation { client: Some("second".into()), ..f.inv() })
.unwrap();
let notifications = f
.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::Notification { plugin_id, actions, .. } => Some((client, plugin_id, actions)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(notifications.len(), 1);
assert_eq!(notifications[0].0.as_deref(), Some("second"));
assert_eq!(notifications[0].1, "jobs");
assert_eq!(notifications[0].2.len(), 3);
assert_eq!(notifications[0].2[0].command, "context");
assert_eq!(notifications[0].2[0].argument.as_deref(), Some("notification-action=1\nvalue"));
for invalid in ["four", "unknown"] {
let error = f.run("jobs", "notify-actions", Some(invalid)).unwrap_err();
assert!(error.contains(if invalid == "four" { "at most 3" } else { "declares no command" }), "{error}");
assert!(f.take_events().iter().all(|(_, e)| !matches!(e, PluginEvent::Notification { .. })));
}
assert_eq!(f.status("jobs"), PluginStatus::Active, "a refused notification leaves the plugin running");
}
#[test]
fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() {
use ide_plugin::Prompt;
let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() });
f.install(&sample("jobs"));
f.host.set_capability("jobs", "project.read", false).unwrap();
let (result, prompt) =
run_and_answer(&f, "pick-directory", Some("C:/work"), Some(&["C:/work/repo"]), Duration::from_millis(700));
assert_eq!(result.unwrap().as_deref(), Some("C:/work/repo"));
assert_eq!(prompt, Prompt::PickDirectory { title: "Clone target".into(), initial: Some("C:/work".into()) });
assert_eq!(run_and_answer(&f, "pick-directory", None, None, Duration::ZERO).0.unwrap().as_deref(), Some("none"));
assert!(run_and_answer(&f, "pick-directory", None, Some(&["relative"]), Duration::ZERO).0.is_err());
assert!(f.run("jobs", "pick-directory", Some("relative")).is_err());
for (cmd, new_window) in [("open-project", false), ("open-project-new", true)] {
f.take_events();
let inv = Invocation { client: Some("second".into()), ..f.inv() };
f.host.run_command("jobs", cmd, Some("C:/work/repo"), inv).unwrap();
let requests = f
.take_events()
.into_iter()
.filter_map(|(client, event)| match event {
PluginEvent::OpenProject { plugin_id, path, new_window } => Some((client, plugin_id, path, new_window)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(requests, vec![(Some("second".into()), "jobs".into(), "C:/work/repo".into(), new_window)]);
let error = f.host.run_command("jobs", cmd, Some("/work/repo"), Invocation::default()).unwrap_err();
assert_eq!(error.kind, ErrorKind::InvalidInput, "{error:?}");
}
assert!(f.host.run_command("jobs", "pick-directory", None, Invocation::default()).is_err());
for path in ["relative", "/bad\npath", ""] {
f.take_events();
assert!(f.run("jobs", "open-project", Some(path)).is_err());
assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. })));
}
}
#[test]
fn plugin_readonly_fields_and_table_row_extensions_round_trip() {
let f = fixture(Limits::default());
f.install(&sample("tables"));
let view = f.host.render_view("tables", "fields", None, f.inv()).unwrap();
let ide_plugin::ViewBody::Form(fields) = view.body else { panic!("form") };
assert_eq!(fields[0].value, ide_plugin::FieldValue::Label("Merge request #42".into()));
assert_eq!(fields[1].value, ide_plugin::FieldValue::ReadonlyText { text: "Ada Lovelace".into(), multiline: false });
assert_eq!(
fields[2].value,
ide_plugin::FieldValue::ReadonlyText { text: "Ready for review\nAll checks passed".into(), multiline: true }
);
assert!(matches!(fields[3].value, ide_plugin::FieldValue::Text(_)));
for (event, status) in [
(ide_plugin::ViewEvent::TableSelect { row: 2 }, "select:2"),
(ide_plugin::ViewEvent::TableActivate { row: 2 }, "activate:2"),
] {
f.take_events();
f.host.view_event("tables", "tables", event, f.inv()).unwrap();
assert!(f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::ViewChanged { plugin_id, view } if plugin_id == "tables" && view == "tables")));
assert_eq!(f.host.render_view("tables", "tables", None, f.inv()).unwrap().status.as_deref(), Some(status));
}
}
#[test]
fn path_filtered_events_do_not_wake_idle_plugins_and_recheck_queued_filters() {
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
f.install(&sample("path-events"));
f.run("test.path-events", "count", None).unwrap();
let matched = to_ipc(&f.project.join("config/app.yaml"));
let other = to_ipc(&f.project.join("src/main.rs"));
let deliver = |events| {
f.host.post_events(Some("main"), events);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
};
std::thread::sleep(Duration::from_millis(150));
f.host.tick();
assert_eq!(f.status("test.path-events"), PluginStatus::Inactive);
let activations = f.host.info_of("test.path-events").unwrap().stats.activations;
deliver(vec![WorkbenchEvent::FileOpened(other.clone()), WorkbenchEvent::FileSaved(other.clone())]);
assert_eq!(f.host.info_of("test.path-events").unwrap().stats.activations, activations);
deliver(vec![WorkbenchEvent::FileOpened(matched.clone()), WorkbenchEvent::FileSaved(matched.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("2"));
assert!(f.run("test.path-events", "filter", Some("../*")).is_err());
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
assert_eq!(
f.run("test.path-events", "count", None).unwrap().as_deref(),
Some("3"),
"invalid filter preserves previous one"
);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(matched.clone())]);
f.run("test.path-events", "filter", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
assert_eq!(
f.run("test.path-events", "count", None).unwrap().as_deref(),
Some("3"),
"queued event uses current filter"
);
deliver(vec![WorkbenchEvent::FileOpened(matched.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("3"));
f.run("test.path-events", "all", None).unwrap();
deliver(vec![WorkbenchEvent::FileOpened(other.clone())]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(other.clone())]);
f.run("test.path-events", "off", None).unwrap();
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(10)));
deliver(vec![WorkbenchEvent::FileOpened(other)]);
assert_eq!(f.run("test.path-events", "count", None).unwrap().as_deref(), Some("4"));
f.host.set_enabled("test.path-events", false).unwrap();
assert!(!f.host.event_kinds().contains(&WorkbenchEvent::FileOpened(String::new()).kind()));
}
#[test]
fn project_lookup_works_in_commands_and_provider_calls_without_an_invocation_project() {
let f = fixture(Limits::default());
f.install(&sample("path-events"));
let path = f.project.join("src/notes.txt");
assert_eq!(f.run("test.path-events", "project", Some(&to_ipc(&path))).unwrap(), Some(to_ipc(&f.project)));
assert_eq!(f.run("test.path-events", "project", Some("src/notes.txt")).unwrap(), None);
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.with_extension("other").join("a")))).unwrap(),
None
);
let doc = ide_plugin::ProviderDocument { path, language: "text".into(), version: 1, text: "one".into() };
let hovers = f.host.provide_hover(&doc, ide_core::Pos::new(0, 0));
assert_eq!(hovers.len(), 1);
assert_eq!(hovers[0].markdown, to_ipc(&f.project));
let nested = f.project.join("nested");
std::fs::create_dir(&nested).unwrap();
f.workspace.other_roots.lock().push(nested.clone());
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&nested.join("new.txt")))).unwrap(),
Some(to_ipc(&nested))
);
#[cfg(unix)]
{
std::os::unix::fs::symlink(&nested, f.project.join("alias")).unwrap();
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("alias/new.txt")))).unwrap(),
Some(to_ipc(&nested))
);
std::os::unix::fs::symlink(f._tmp.path(), f.project.join("outside")).unwrap();
assert_eq!(
f.run("test.path-events", "project", Some(&to_ipc(&f.project.join("outside/new.txt")))).unwrap(),
None
);
}
f.host.set_capability("test.path-events", "project.read", false).unwrap();
assert!(f.run("test.path-events", "project", Some(&to_ipc(&doc.path))).unwrap_err().contains("project.read"));
f.host.set_capability("test.path-events", "events", false).unwrap();
assert!(f.run("test.path-events", "filter", Some("*")).unwrap_err().contains("events"));
}
#[test]
fn view_messages_accept_multi_megabyte_snapshots_and_cap_at_8_mib() {
let f = fixture(Limits::default());
f.install(&sample("jobs"));
f.take_events();
let two_mib = "x".repeat(2 << 20);
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: two_mib.clone() }, f.inv()).unwrap();
let echoed = f
.take_events()
.into_iter()
.find_map(|(_, e)| match e {
PluginEvent::ViewMessage { view, message, .. } if view == "jobs" => Some(message),
_ => None,
})
.expect("2 MiB view message round-trips");
assert_eq!(echoed.len(), 2 << 20);
let too_large = "x".repeat((8 << 20) + 1);
let err =
f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Message { text: too_large }, f.inv()).unwrap_err();
assert!(err.message.contains("limited to"), "{}", err.message);
}
#[test]
fn web_content_fixture_installs_and_bridges_view_messages() {
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&sample("web-content"), &Consent::default()).unwrap();
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
assert_eq!(f.run("web-content", "ping", None).unwrap().as_deref(), Some("Web content ready"));
f.take_events();
assert_eq!(f.run("web-content", "open", None).unwrap(), None);
assert!(
f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::ViewMessage { plugin_id, view, message }
if plugin_id == "web-content" && view == "inspector" && message == "open"))
);
}
#[test]
fn web_log_tabs_use_the_web_pty_permission_boundary() {
use ide_host::plugins::web::WebPtyCommand;
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&sample("web-log"), &Consent::default()).unwrap();
assert!(info.capabilities.iter().any(|c| c.id == "process:bash" && !c.granted));
assert_eq!(f.run("web-log", "ping", None).unwrap().as_deref(), Some("Web log ready"));
for command in ["open", "denied"] {
f.take_events();
assert_eq!(f.run("web-log", command, None).unwrap(), None);
assert!(
f.take_events()
.iter()
.any(|(_, event)| matches!(event, PluginEvent::ViewMessage { plugin_id, view, message }
if plugin_id == "web-log" && view == "logs" && message == command))
);
}
let command = |program: &str| WebPtyCommand {
program: program.into(),
args: vec!["--noprofile".into(), "--norc".into()],
env: Default::default(),
cols: 240,
rows: 40,
};
for program in ["bash", "sh", "/bin/bash"] {
assert_eq!(
f.host.web_pty_options("web-log", "logs", "main", command(program)).unwrap_err().kind,
ErrorKind::PermissionDenied
);
}
#[cfg(unix)]
{
f.host.set_capability("web-log", "process:bash", true).unwrap();
let options = f.host.web_pty_options("web-log", "logs", "main", command("bash")).unwrap();
assert_eq!(options.shell.as_deref(), f.host.program_for("web-log", "bash").unwrap().to_str());
assert_eq!(options.cwd.0, f.project);
assert!(options.env.is_empty());
assert!(!options.shell_integration);
assert!(f.host.web_pty_options("web-log", "logs", "main", command("sh")).is_err());
f.host.set_capability("web-log", "process:bash", false).unwrap();
assert!(f.host.web_pty_options("web-log", "logs", "main", command("bash")).is_err());
}
}
/// `views.open-log-tab` from the plugin's code: the host remembers what the tab
/// may run, and the tab starts it by key, through the same PTY checks.
#[test]
fn log_tabs_opened_by_code_run_only_what_the_host_approved() {
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("web-log"), &Consent::default()).unwrap();
let err = f.run("web-log", "code", None).unwrap_err();
assert!(err.contains("process:bash"), "{err}");
let options = |key: &str, window: &str, on: &[&str]| {
let on: Vec<String> = on.iter().map(|s| s.to_string()).collect();
f.host.log_tab_pty_options("web-log", key, window, &on, (240, 40))
};
assert_eq!(options("code", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
#[cfg(unix)]
{
f.host.set_capability("web-log", "process:bash", true).unwrap();
f.take_events();
assert_eq!(f.run("web-log", "code", None).unwrap(), None);
let events = f.take_events();
let (client, log) = events
.iter()
.find_map(|(client, event)| match event {
PluginEvent::OpenLogTab { plugin_id, key, title, log }
if plugin_id == "web-log" && key == "code" && title == "Probe logs (code)" =>
{
Some((client.clone(), log.clone()))
}
_ => None,
})
.expect("an OpenLogTab event");
assert_eq!(client.as_deref(), Some("main"));
assert_eq!((log.program.as_str(), log.toggles[0].id.as_str()), ("bash", "verbose"));
let got = options("code", "main", &[]).unwrap();
assert_eq!(got.shell.as_deref(), f.host.program_for("web-log", "bash").unwrap().to_str());
assert_eq!(got.args, log.args);
assert_eq!(got.cwd.0, f.project);
assert!(got.env.is_empty() && !got.shell_integration);
// Toggles append their arguments; unknown ones add nothing.
let verbose = options("code", "main", &["verbose", "bogus"]).unwrap();
assert_eq!(verbose.args.last().map(String::as_str), Some("--verbose"));
assert_eq!(verbose.args.len(), log.args.len() + 1);
// Only the window it was opened in, and only that key.
assert_eq!(options("code", "other", &[]).unwrap_err().kind, ErrorKind::NotFound);
assert_eq!(options("probe", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
// A revoked grant stops it starting, and forgets the tab.
f.host.set_capability("web-log", "process:bash", false).unwrap();
assert!(options("code", "main", &[]).is_err());
f.host.set_capability("web-log", "process:bash", true).unwrap();
assert_eq!(options("code", "main", &[]).unwrap_err().kind, ErrorKind::NotFound);
}
}
#[test]
fn web_ptys_require_a_current_exact_program_grant_and_project() {
use ide_host::plugins::web::WebPtyCommand;
let f = fixture(Limits::default());
let info = f.host.install_from_dir(&sample("web-pty"), &Consent::default()).unwrap();
assert!(info.capabilities.iter().any(|c| c.id == "process:git" && !c.granted));
let command = || WebPtyCommand {
program: "git".into(),
args: vec!["--version".into()],
env: Default::default(),
cols: 80,
rows: 24,
};
let options = |cmd| f.host.web_pty_options("web-pty", "terminal", "main", cmd);
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
f.host.set_capability("web-pty", "process:git", true).unwrap();
let approved = f.host.program_for("web-pty", "git").unwrap();
let got = options(command()).unwrap();
assert_eq!(got.shell.as_deref(), approved.to_str());
assert!(approved.is_absolute());
assert_eq!(got.cwd.0, f.project);
assert!(!got.shell_integration);
assert_eq!(f.run("web-pty", "ping", None).unwrap().as_deref(), Some("Web PTY ready"));
for program in ["sh", "bash", "git-other", "./git", "/tmp/git"] {
let cmd = WebPtyCommand { program: program.into(), ..command() };
assert!(options(cmd).is_err(), "{program}");
}
for cmd in [
WebPtyCommand { cols: 0, ..command() },
WebPtyCommand { rows: 501, ..command() },
WebPtyCommand { args: vec!["x".repeat(4097)], ..command() },
WebPtyCommand { args: vec!["x".into(); 257], ..command() },
WebPtyCommand { args: vec!["\0".into()], ..command() },
WebPtyCommand { env: [("PATH".into(), "/tmp".into())].into(), ..command() },
WebPtyCommand { env: [("a=b".into(), "x".into())].into(), ..command() },
WebPtyCommand { env: [("a".into(), "x".repeat(4097))].into(), ..command() },
] {
assert!(options(cmd).is_err());
}
let got = options(WebPtyCommand { env: [("OPENIDEA_WEB_PTY_TEST_NEW".into(), "yes".into())].into(), ..command() })
.unwrap();
assert_eq!(got.env["OPENIDEA_WEB_PTY_TEST_NEW"], "yes");
assert!(f.host.web_pty_options("web-pty", "missing", "main", command()).is_err());
f.host.set_workspace(None);
assert!(options(command()).is_err());
let mut untrusted = TestWorkspace::new(&f.project);
Arc::get_mut(&mut untrusted).unwrap().trusted = false;
f.host.set_workspace(Some(untrusted));
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
f.host.set_workspace(Some(f.workspace.clone()));
std::fs::rename(&f.project, f.project.with_extension("moved")).unwrap();
assert!(options(command()).unwrap_err().message.contains("no longer exists"));
std::fs::rename(f.project.with_extension("moved"), &f.project).unwrap();
f.host.set_capability("web-pty", "process:git", false).unwrap();
assert_eq!(options(command()).unwrap_err().kind, ErrorKind::PermissionDenied);
f.host.set_enabled("web-pty", false).unwrap();
assert!(options(command()).is_err());
f.host.uninstall("web-pty").unwrap();
assert!(options(command()).is_err());
}
#[cfg(unix)]
#[test]
fn web_ptys_keep_the_approved_file_and_require_api_1_3() {
use ide_host::plugins::web::WebPtyCommand;
use std::os::unix::fs::PermissionsExt;
let f = fixture(Limits::default());
let bin = f.plugins_dir.parent().unwrap().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let executable = bin.join("git");
std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = bin.clone();
f.host.set_program_path(Arc::new(move || Some(path.clone().into_os_string())));
f.install(&sample("web-pty"));
let command = || WebPtyCommand { program: "git".into(), args: vec![], env: Default::default(), cols: 80, rows: 24 };
f.host.set_program_path(Arc::new(|| Some("/unapproved-path".into())));
assert_eq!(
f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap().shell.unwrap(),
executable.canonicalize().unwrap().to_string_lossy()
);
std::fs::write(&executable, "#!/bin/sh\nexit 42\n").unwrap();
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("changed"));
std::fs::remove_file(&executable).unwrap();
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).is_err());
let old = tempfile::tempdir().unwrap();
let pkg = old.path().join("old");
copy_dir(&sample("web-pty"), &pkg);
let manifest = pkg.join("plugin.toml");
let text = std::fs::read_to_string(&manifest).unwrap().replace("^1.3", "^1.2");
std::fs::write(manifest, text).unwrap();
let f = fixture(Limits::default());
f.install(&pkg);
assert!(f.host.web_pty_options("web-pty", "terminal", "main", command()).unwrap_err().message.contains("1.3"));
}
#[tokio::test]
async fn web_ptys_are_capped_across_windows_and_end_with_the_plugin() {
use ide_host::{Host, HostConfig, plugins::web::WebPtyCommand};
let f = fixture(Limits::default());
f.install(&sample("web-pty"));
let root = tempfile::tempdir().unwrap();
let host = Arc::new(Host::new(HostConfig {
config_dir: root.path().join("config"),
data_dir: root.path().join("data"),
local_data_dir: root.path().join("local"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}));
let spawn = |window: &str| {
f.host.spawn_web_pty(
&host,
"web-pty",
"terminal",
window,
WebPtyCommand {
program: "git".into(),
args: vec!["hash-object".into(), "--stdin".into()],
env: Default::default(),
cols: 80,
rows: 24,
},
Arc::new(|_| {}),
)
};
*f.workspace.project_queries_left.lock() = Some(1);
assert!(spawn("main").is_err(), "project closed while spawning");
assert!(host.terminals.list().is_empty());
*f.workspace.project_queries_left.lock() = None;
let mut ids = Vec::new();
for i in 0..8 {
ids.push(spawn(&format!("window-{i}")).unwrap().id);
}
assert!(spawn("ninth-window").unwrap_err().message.contains("8 PTYs"));
host.terminals.kill(ids[0]).unwrap();
let replacement = spawn("another-window").unwrap();
f.host.reload("web-pty").unwrap();
assert!(!host.terminals.contains(replacement.id));
assert!(ids.into_iter().all(|id| !host.terminals.contains(id)));
let id = spawn("main").unwrap().id;
f.host.set_capability("web-pty", "process:git", false).unwrap();
assert!(!host.terminals.contains(id));
assert!(spawn("main").is_err());
f.host.set_capability("web-pty", "process:git", true).unwrap();
let id = spawn("main").unwrap().id;
f.host.set_enabled("web-pty", false).unwrap();
assert!(!host.terminals.contains(id));
f.host.set_enabled("web-pty", true).unwrap();
let id = spawn("main").unwrap().id;
f.host.forget_client("main");
assert!(!host.terminals.contains(id));
assert!(spawn("main").is_err());
f.host.reopen_client("main");
let id = spawn("main").unwrap().id;
f.host.uninstall("web-pty").unwrap();
assert!(!host.terminals.contains(id));
}
#[test]
fn marker_icons_ship_sanitized_light_and_dark_assets() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("plugin");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
let manifest = |engine: &str, icon: &str, dark: &str| {
format!(
r#"
id = "marker-icons"
name = "Marker Icons"
version = "1.0.0"
engines.openidea = "{engine}"
[[contributes.markerIcons]]
id = "resource"
icon = "{icon}"
iconDark = "{dark}"
"#
)
};
let clean = r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#;
std::fs::write(pkg.join("icons/resource.svg"), clean).unwrap();
std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap();
for (engine, icon, dark) in [
("^1.0", "icons/resource.svg", "icons/resource_dark.svg"),
("^1.3", "../outside.svg", "icons/resource_dark.svg"),
("^1.3", "icons/missing.svg", "icons/resource_dark.svg"),
("^1.3", "icons/resource.svg", "icons/missing.svg"),
] {
std::fs::write(pkg.join("plugin.toml"), manifest(engine, icon, dark)).unwrap();
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
}
std::fs::write(pkg.join("plugin.toml"), manifest("^1.3", "icons/resource.svg", "icons/resource_dark.svg")).unwrap();
#[cfg(unix)]
{
let outside = tmp.path().join("outside.svg");
std::fs::write(&outside, clean).unwrap();
std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
std::os::unix::fs::symlink(outside, pkg.join("icons/resource_dark.svg")).unwrap();
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err());
std::fs::remove_file(pkg.join("icons/resource_dark.svg")).unwrap();
}
let mut unsafe_svgs = vec![
"<svg><script>alert(1)</script></svg>".to_owned(),
"<svg onload=\"alert(1)\"/>".to_owned(),
"<svg><foreignObject/></svg>".to_owned(),
"<svg><rect style=\"fill:url(https://evil/x)\"/></svg>".to_owned(),
"<svg><rect style=\"background-image:image-set('https://evil/x' 1x)\"/></svg>".to_owned(),
format!("<svg>{}</svg>", " ".repeat(64 << 10)),
];
for attr in ["href", "xlink:href"] {
for scheme in ["http://evil/x", "data:image/svg+xml,x", "javascript:alert(1)"] {
unsafe_svgs.push(format!("<svg><use {attr}=\"{scheme}\"/></svg>"));
}
}
for path in ["icons/resource.svg", "icons/resource_dark.svg"] {
for svg in &unsafe_svgs {
std::fs::write(pkg.join(path), svg).unwrap();
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "accepted {path}: {svg}");
}
std::fs::write(pkg.join(path), clean).unwrap();
}
std::fs::write(pkg.join("icons/resource_dark.svg"), clean.replace("rect", "circle")).unwrap();
let info = f.install(&pkg);
assert!(info.error.is_none(), "{:?}", info.error);
let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap();
assert_eq!(assets.len(), 2);
assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains("<rect")));
assert!(assets.iter().any(|(id, svg)| id.ends_with("/dark") && svg.contains("<circle")));
let revision = f.host.ui_items("markerIcons")[0].decl["revision"].clone();
std::fs::write(pkg.join("icons/resource.svg"), clean.replace("width=\"16\"", "width=\"14\"")).unwrap();
let updated = f.install(&pkg);
assert!(updated.error.is_none(), "{:?}", updated.error);
assert_ne!(f.host.ui_items("markerIcons")[0].decl["revision"], revision);
let assets = f.host.assets("marker-icons", "markerIcon/resource/").unwrap();
assert!(assets.iter().any(|(id, svg)| id.ends_with("/light") && svg.contains("width=\"14\"")));
std::fs::remove_dir_all(pkg).unwrap();
assert_eq!(f.host.assets("marker-icons", "markerIcon/resource/").unwrap(), assets);
f.host.set_enabled("marker-icons", false).unwrap();
assert!(f.host.assets("marker-icons", "markerIcon/resource/").is_err());
}
#[test]
fn marker_providers_preserve_names_and_package_paths() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("plugin");
copy_dir(&misbehave_pkg(), &pkg);
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!("{manifest}\n[[contributes.providers]]\nkinds = [\"line-markers\"]\nlanguages = [\"text\"]\n"),
)
.unwrap();
f.install(&pkg);
let doc = ide_plugin::ProviderDocument {
path: f.project.join("markers.txt"),
language: "text".into(),
version: 1,
text: "marker-icons\nordinary\ncustom\nlegacy".into(),
};
assert!(eventually(|| f.host.provide_line_markers(&doc).len() == 4));
let markers = f.host.provide_line_markers(&doc);
assert_eq!(
markers.iter().map(|(_, m)| m.icon.as_deref().unwrap()).collect::<Vec<_>>(),
["run", "database", "icons/very-long-resource-marker-icon.svg", "overriding"]
);
}
/// The released WIT variant stays unchanged: SDK decodes other(files-changed).
#[test]
fn files_changed_wakes_idle_once_and_keeps_every_delta() {
let f = fixture(Limits::default());
f.install(&sample("files-events"));
assert!(f.host.event_kinds().contains(&EventKind::FilesChanged));
assert_eq!(f.host.instances(), 0);
for i in 0..600 {
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
created: vec![to_ipc(&f.project.join(format!("新建😀-{i}.rs")))],
modified: vec![to_ipc(&f.project.join("shared.rs"))],
removed: vec![to_ipc(&f.project.join("gone.rs"))],
})],
);
}
// Already debounced by the watcher; no two-second idle delay.
f.host.flush_events();
assert!(f.host.wait_events(Duration::from_secs(10)));
let batches: Vec<ide_plugin::FilesChanged> =
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
assert_eq!(batches.len(), 1);
assert_eq!(batches[0].created.len(), 600);
assert_eq!(batches[0].modified, [to_ipc(&f.project.join("shared.rs"))]);
assert_eq!(batches[0].removed, [to_ipc(&f.project.join("gone.rs"))]);
for i in 0..3 {
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
modified: vec![to_ipc(&f.project.join(format!("delta-{i}.rs")))],
..Default::default()
})],
);
f.host.flush_events();
}
assert!(f.host.wait_events(Duration::from_secs(10)));
let batches: Vec<ide_plugin::FilesChanged> =
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
let mut later: Vec<_> = batches[1..].iter().flat_map(|b| b.modified.iter().cloned()).collect();
later.sort();
assert_eq!(
later,
(0..3).map(|i| to_ipc(&f.project.join(format!("delta-{i}.rs")))).collect::<Vec<_>>(),
"later deltas must not cancel earlier batches"
);
}
#[test]
fn files_changed_requires_events_capability() {
let f = fixture(Limits::default());
let dir = f._tmp.path().join("without-events");
copy_dir(&sample("files-events"), &dir);
let manifest = dir.join("plugin.toml");
std::fs::write(
&manifest,
std::fs::read_to_string(&manifest).unwrap().replace("[\"events\", \"storage\"]", "[\"storage\"]"),
)
.unwrap();
f.install(&dir);
assert!(!f.host.event_kinds().contains(&EventKind::FilesChanged));
f.host.post_events(Some("main"), vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged::default())]);
f.host.flush_events();
assert_eq!(f.host.instances(), 0, "an ungranted plugin must not wake");
}
#[test]
fn files_changed_does_not_cancel_a_batch_blocked_in_a_host_call() {
let f = fixture(Limits::default());
f.install(&sample("files-events"));
std::fs::write(f.project.join("event-barrier.txt"), "ready").unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
*f.workspace.event_read_gate.lock() = Some((entered, released));
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
created: vec![to_ipc(&f.project.join("__block__.txt"))],
..Default::default()
})],
);
f.host.flush_events();
waiting.recv_timeout(Duration::from_secs(10)).expect("first event reaches its host call");
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
removed: vec![to_ipc(&f.project.join("gone.txt"))],
..Default::default()
})],
);
f.host.flush_events();
release.send(()).unwrap();
assert!(f.host.wait_events(Duration::from_secs(10)));
let batches: Vec<ide_plugin::FilesChanged> =
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
assert_eq!(batches.len(), 2, "a later filesystem delta cannot discard an earlier one");
assert_eq!(batches[0].created, [to_ipc(&f.project.join("__block__.txt"))]);
assert_eq!(batches[1].removed, [to_ipc(&f.project.join("gone.txt"))]);
}
#[test]
fn files_changed_rechecks_filters_when_an_actor_batch_starts() {
for filter in ["", "other/**"] {
let f = fixture(Limits::default());
f.install(&sample("files-events"));
f.run("files-events", "filter-after-event", Some(filter)).unwrap();
std::fs::write(f.project.join("event-barrier.txt"), "ready").unwrap();
let (entered, waiting) = std::sync::mpsc::channel();
let (release, released) = std::sync::mpsc::channel();
*f.workspace.event_read_gate.lock() = Some((entered, released));
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
created: vec![to_ipc(&f.project.join("__block__.txt"))],
..Default::default()
})],
);
f.host.flush_events();
waiting.recv_timeout(Duration::from_secs(10)).expect("first batch blocks before replacing its filter");
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
modified: vec![to_ipc(&f.project.join("queued.txt"))],
..Default::default()
})],
);
f.host.flush_events();
release.send(()).unwrap();
assert!(f.host.wait_events(Duration::from_secs(10)));
let batches: Vec<ide_plugin::FilesChanged> =
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
assert_eq!(batches.len(), 1, "filter {filter:?} must suppress the batch waiting in the actor");
}
}
#[test]
fn files_changed_filters_each_category_before_waking_idle_plugins() {
let f = fixture(Limits { idle_timeout: Some(Duration::ZERO), ..Limits::default() });
f.install(&sample("files-events"));
f.run("files-events", "filter", Some("config/*.yaml")).unwrap();
assert!(eventually(|| {
f.host.tick();
f.host.instances() == 0
}));
let path = |relative: &str| to_ipc(&f.project.join(relative));
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
created: vec![path("src/unrelated.rs")],
modified: vec![path("notes.txt")],
removed: vec![path("elsewhere/a.yaml")],
})],
);
f.host.flush_events();
assert_eq!(f.host.instances(), 0, "unmatched batches do not wake an idle plugin");
f.host.post_events(
Some("main"),
vec![WorkbenchEvent::FilesChanged(ide_plugin::FilesChanged {
created: vec![path("config/new.yaml"), path("src/unrelated.rs")],
modified: vec![path("config/changed.yaml"), path("notes.txt")],
removed: vec![path("config/gone.yaml"), path("elsewhere/a.yaml")],
})],
);
f.host.flush_events();
assert!(f.host.wait_events(Duration::from_secs(10)));
let received: Vec<ide_plugin::FilesChanged> =
serde_json::from_str(&f.run("files-events", "events", None).unwrap().unwrap()).unwrap();
assert_eq!(
received,
[ide_plugin::FilesChanged {
created: vec![path("config/new.yaml")],
modified: vec![path("config/changed.yaml")],
removed: vec![path("config/gone.yaml")],
}]
);
}
#[test]
fn view_header_none_needs_api_1_3_and_an_ungrouped_web_view() {
let f = fixture(Limits::default());
let tmp = tempfile::tempdir().unwrap();
let pkg = tmp.path().join("headerless");
std::fs::create_dir_all(pkg.join("web")).unwrap();
std::fs::write(pkg.join("web/index.html"), "<!doctype html><p>hi</p>").unwrap();
let manifest = |engine: &str, view_fields: &str| {
format!(
"id = \"headerless\"\nname = \"Headerless\"\nversion = \"1.0.0\"\nengines.openidea = \"{engine}\"\n[[contributes.views]]\nid = \"panel\"\ntitle = \"Panel\"\nlocation = \"bottom\"\n{view_fields}\n"
)
};
for (engine, fields, expected) in [
("^1.2", "web = \"web/index.html\"\nheader = \"none\"", "new in plugin API 1.3"),
("^1.3", "web = \"web/index.html\"\nheader = \"custom\"", "default or none"),
("^1.3", "header = \"none\"", "give a web page"),
("^1.3", "web = \"web/index.html\"\ngroup = \"g\"\nheader = \"none\"", "ungrouped"),
] {
std::fs::write(pkg.join("plugin.toml"), manifest(engine, fields)).unwrap();
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err().message;
assert!(err.contains(expected), "{fields}: {err}");
}
std::fs::write(pkg.join("plugin.toml"), manifest("^1.3", "web = \"web/index.html\"\nheader = \"none\"")).unwrap();
let info = f.install(&pkg);
assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error);
assert_eq!(f.host.ui_items("views")[0].decl["header"], "none");
}