mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
Plugins read problems from a host-owned snapshot that records every Diagnostics event per file and producer, instead of the UI-reported Problems reports, so what a plugin sees no longer depends on window lifetime or the invoking window. Adds the problems-changed workbench event (plugin API 1.8.0, gated at needs_api 8), editor focus events for files of other windows, severity style previews in plugin settings, and lays plugin annotations on the line-end chain before code-vision hints and inline blame. Components and fixtures are rebuilt for API 1.8.0. Co-authored-by: muutot <92625353+muutot@users.noreply.github.com>
556 lines
27 KiB
Rust
556 lines
27 KiB
Rust
use ide_core::{CancellationToken, Diagnostic, Pos, Range, Severity};
|
|
use ide_host::plugins::{HostWorkspace, PluginWorkspace};
|
|
use ide_host::{Host, HostConfig};
|
|
use ide_plugin::TextQuery;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
struct Harness {
|
|
host: Arc<Host>,
|
|
root: PathBuf,
|
|
_tmp: tempfile::TempDir,
|
|
}
|
|
impl Harness {
|
|
fn new() -> Self {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let host = Arc::new(Host::new(HostConfig {
|
|
config_dir: tmp.path().join("config"),
|
|
data_dir: tmp.path().join("data"),
|
|
local_data_dir: tmp.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,
|
|
}));
|
|
host.open_project("main", &root).unwrap();
|
|
host.open_project("second", &root).unwrap();
|
|
Self { host, root, _tmp: tmp }
|
|
}
|
|
fn workspace(&self) -> HostWorkspace {
|
|
HostWorkspace(Arc::downgrade(&self.host))
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn window_editor_reports_use_document_language_dirty_state_and_focus() {
|
|
let f = Harness::new();
|
|
let path = f.root.join("main.rs");
|
|
std::fs::write(&path, "fn main() {}\n").unwrap();
|
|
let p = f.host.project("main").unwrap();
|
|
let doc = p.documents.open(&path).unwrap();
|
|
p.documents
|
|
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "// unsaved\n".into() }])
|
|
.unwrap();
|
|
f.host.mcp_reports.set_open_editors("main", vec![path.clone()]);
|
|
f.host.mcp_reports.set_focused_editor(
|
|
"main",
|
|
Some(ide_host::mcp::FocusedEditor {
|
|
path: path.clone().into(),
|
|
selection: Range::default(),
|
|
selected_text: String::new(),
|
|
}),
|
|
true,
|
|
);
|
|
let workspace = f.workspace();
|
|
let editors = workspace.open_editors("main").unwrap();
|
|
assert_eq!(editors.len(), 1);
|
|
assert!(editors[0].dirty && editors[0].active);
|
|
assert_eq!(editors[0].language, "rust");
|
|
assert!(workspace.open_editors("second").unwrap().is_empty());
|
|
let second = f.host.project("second").unwrap();
|
|
second.documents.open(&path).unwrap();
|
|
f.host.mcp_reports.set_open_editors("second", vec![path.clone()]);
|
|
assert!(!workspace.open_editors("second").unwrap()[0].dirty);
|
|
assert!(workspace.open_editors("main").unwrap()[0].dirty);
|
|
assert!(workspace.buffer_text_in(Some("main"), &path).unwrap().contains("unsaved"));
|
|
assert!(!workspace.buffer_text_in(Some("second"), &path).unwrap().contains("unsaved"));
|
|
assert_ne!(workspace.document_in("main", &path), workspace.document_in("second", &path));
|
|
f.host.mcp_reports.forget_window("main");
|
|
assert!(workspace.open_editors("main").unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn text_search_uses_overlay_excludes_utf16_and_limits() {
|
|
let f = Harness::new();
|
|
let path = f.root.join("a.txt");
|
|
std::fs::write(&path, "disk\n").unwrap();
|
|
std::fs::create_dir(f.root.join("target")).unwrap();
|
|
std::fs::write(f.root.join("target/ignored.txt"), "needle").unwrap();
|
|
let p = f.host.project("main").unwrap();
|
|
p.settings
|
|
.set(ide_workspace::settings::SettingsScope::Project, "files.ignoredNames", serde_json::json!(["target"]))
|
|
.unwrap();
|
|
let doc = p.documents.open(&path).unwrap();
|
|
p.documents
|
|
.apply_edits(doc.id, &[ide_core::TextEdit { range: Range::default(), new_text: "😀needle\nneedle\n".into() }])
|
|
.unwrap();
|
|
f.host.project("second").unwrap().documents.open(&path).unwrap();
|
|
let workspace = f.workspace();
|
|
let query = TextQuery { pattern: "needle".into(), case_sensitive: true, ..Default::default() };
|
|
let found = workspace.search_text(Some("main"), &f.root, query.clone(), 10, &CancellationToken::new()).unwrap();
|
|
assert_eq!(found.matches.len(), 2, "{found:?}; text={:?}", workspace.buffer_text(&path));
|
|
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
|
|
assert!(found.matches.iter().all(|m| m.location.path == path));
|
|
let only_file = TextQuery { include: vec!["a.txt".into()], ..query.clone() };
|
|
assert!(
|
|
workspace
|
|
.search_text(Some("second"), &f.root, only_file, 10, &CancellationToken::new())
|
|
.unwrap()
|
|
.matches
|
|
.is_empty()
|
|
);
|
|
let capped = workspace.search_text(Some("main"), &f.root, query.clone(), 1, &CancellationToken::new()).unwrap();
|
|
assert_eq!(capped.matches.len(), 1);
|
|
assert!(capped.limit_hit);
|
|
let excluded = TextQuery { exclude: vec!["*.txt".into()], ..query.clone() };
|
|
assert!(
|
|
workspace
|
|
.search_text(Some("main"), &f.root, excluded, 10, &CancellationToken::new())
|
|
.unwrap()
|
|
.matches
|
|
.is_empty()
|
|
);
|
|
for query in [
|
|
TextQuery { pattern: "NEEDLE".into(), case_sensitive: true, ..Default::default() },
|
|
TextQuery { pattern: "need".into(), whole_word: true, ..Default::default() },
|
|
TextQuery { include: vec!["*.rs".into()], ..query.clone() },
|
|
] {
|
|
assert!(
|
|
workspace
|
|
.search_text(Some("main"), &f.root, query, 10, &CancellationToken::new())
|
|
.unwrap()
|
|
.matches
|
|
.is_empty()
|
|
);
|
|
}
|
|
let multiline = TextQuery { pattern: "needle\nneedle".into(), regex: true, ..Default::default() };
|
|
let found = workspace.search_text(Some("main"), &f.root, multiline, 10, &CancellationToken::new()).unwrap();
|
|
assert_eq!(found.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(1, 6)));
|
|
let cancelled = CancellationToken::new();
|
|
cancelled.cancel();
|
|
assert!(matches!(
|
|
workspace.search_text(Some("main"), &f.root, query, 10, &cancelled),
|
|
Err(ide_plugin::HostError::Cancelled)
|
|
));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn problems_come_from_the_host_snapshot_whatever_window_published_them() {
|
|
let f = Harness::new();
|
|
let path = f.root.join("a.txt");
|
|
std::fs::write(&path, "text").unwrap();
|
|
let diagnostic = |source: &str| Diagnostic {
|
|
range: Range::default(),
|
|
severity: Severity::Warning,
|
|
message: source.into(),
|
|
source: Some(source.into()),
|
|
code: None,
|
|
unnecessary: false,
|
|
deprecated: false,
|
|
server: None,
|
|
};
|
|
// Producers publish per (file, producer) through the event sink, like the
|
|
// language servers and the spelling check do.
|
|
let publish = |host: &Host, source: &str, items: Vec<Diagnostic>| {
|
|
host.project("main").unwrap().sink.emit(ide_core::IdeEvent::Diagnostics {
|
|
path: path.clone().into(),
|
|
source: source.into(),
|
|
items,
|
|
});
|
|
};
|
|
publish(&f.host, "rust-analyzer", vec![diagnostic("lsp")]);
|
|
publish(&f.host, "build", vec![diagnostic("plugin")]);
|
|
// A file outside the window's project is visible too: annotations follow the file.
|
|
let outside = f.host.diagnostics.problems().len();
|
|
let workspace = f.workspace();
|
|
let files = workspace.problems("main", Some(&path)).unwrap();
|
|
assert_eq!(files.len(), 2);
|
|
assert!(files.iter().all(|f| f.diagnostics.len() == 1));
|
|
assert!(workspace.problems("main", Some(&f.root.join("other.txt"))).unwrap().is_empty());
|
|
assert_eq!(outside, 2);
|
|
// A full replacement per producer clears only that producer.
|
|
publish(&f.host, "rust-analyzer", vec![]);
|
|
let files = workspace.problems("main", None).unwrap();
|
|
assert_eq!(files.len(), 1);
|
|
assert_eq!(files[0].source, "build");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn search_decodes_closed_documents_the_same_way_as_the_editor() {
|
|
let f = Harness::new();
|
|
let path = f.root.join("utf16.txt");
|
|
let mut bytes = vec![0xff, 0xfe];
|
|
for unit in "😀needle\r\n".encode_utf16() {
|
|
bytes.extend_from_slice(&unit.to_le_bytes());
|
|
}
|
|
std::fs::write(&path, bytes).unwrap();
|
|
let query = TextQuery { pattern: "needle".into(), ..Default::default() };
|
|
let result = f.workspace().search_text(Some("main"), &f.root, query, 10, &CancellationToken::new()).unwrap();
|
|
assert_eq!(result.matches.len(), 1);
|
|
assert_eq!(result.matches[0].location.range, Range::new(Pos::new(0, 2), Pos::new(0, 8)));
|
|
assert_eq!(result.matches[0].line, "😀needle");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn plugin_debug_uses_the_source_language_and_revalidates_its_owned_program() {
|
|
use ide_host::plugins::{Consent, PluginEvent, PluginHost, WindowRequest};
|
|
use ide_plugin::{Invocation, Limits};
|
|
let f = Harness::new();
|
|
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
|
std::fs::write(f.root.join("execution.marker"), "").unwrap();
|
|
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
|
|
let events = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
|
let captured = events.clone();
|
|
let plugins = Arc::new(
|
|
PluginHost::new(
|
|
f._tmp.path().join("plugins"),
|
|
Arc::new(move |client: Option<&str>, event: PluginEvent| {
|
|
captured.lock().push((client.map(str::to_owned), event))
|
|
}),
|
|
Limits::default(),
|
|
)
|
|
.unwrap(),
|
|
);
|
|
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
|
f.host.set_plugin_host(&plugins);
|
|
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
|
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
|
plugins
|
|
.choose_program("execution", "process:execution-tool", Some(&f.root), Some(std::path::Path::new("/bin/cat")))
|
|
.unwrap();
|
|
let mut config =
|
|
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
|
|
config.args = vec!["entry.py".into()];
|
|
ide_run::save_user_configs(&f.root, &[config]).unwrap();
|
|
let config = f.workspace().run_configuration("main", "execution", "detected").unwrap();
|
|
assert_eq!(config.debug_adapter.as_deref(), Some("debugpy"));
|
|
let pinned = plugins.program_for_project("execution", "execution-tool", Some(&f.root)).unwrap();
|
|
assert_eq!(plugins.prepare_debug_run("main", config.clone()).unwrap().command, pinned.to_string_lossy());
|
|
let invocation =
|
|
Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
|
assert!(plugins.run_command("execution", "debug", None, invocation).unwrap().is_some());
|
|
assert!(events.lock().iter().any(|(client, event)| client.as_deref() == Some("main") && matches!(event, PluginEvent::WindowRequest { request: WindowRequest::Run { config, debug: true, .. }, .. } if config.command == pinned.to_string_lossy())));
|
|
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
|
|
assert!(plugins.prepare_debug_run("main", config).is_err());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
|
|
use ide_host::plugins::{Consent, PluginEvent, PluginHost, run_targets};
|
|
use ide_plugin::{Invocation, Limits};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let f = Harness::new();
|
|
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
|
let plugins = Arc::new(
|
|
PluginHost::new(
|
|
f._tmp.path().join("plugins"),
|
|
Arc::new(|_: Option<&str>, _: PluginEvent| {}),
|
|
Limits { idle_timeout: Some(std::time::Duration::from_millis(10)), ..Default::default() },
|
|
)
|
|
.unwrap(),
|
|
);
|
|
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
|
f.host.set_plugin_host(&plugins);
|
|
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
|
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
|
let tool = f.root.with_file_name("wrapper");
|
|
std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\n").unwrap();
|
|
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let invocation =
|
|
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
|
assert!(plugins.run_command("execution", "target", None, invocation()).is_err());
|
|
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
|
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
|
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
|
plugins.tick_at(std::time::Instant::now());
|
|
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
|
|
let targets = run_targets::list(&f.host, &plugins, "main").unwrap();
|
|
assert_eq!(targets.len(), 1);
|
|
assert!(targets[0].prompt);
|
|
assert!(f.host.project("main").unwrap().settings.effective().run.default_target.is_empty());
|
|
let id = &targets[0].id;
|
|
let settings = run_targets::answer(&f.host, &plugins, "main", id, false).unwrap();
|
|
assert!(settings.run.default_target.is_empty());
|
|
// A stale prompt in a second window cannot change an answered selection.
|
|
assert!(run_targets::answer(&f.host, &plugins, "second", id, true).unwrap().run.default_target.is_empty());
|
|
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
|
|
assert!(plugins.run_targets(&f.root).is_empty());
|
|
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
|
assert!(!plugins.run_targets(&f.root)[0].prompt);
|
|
plugins.run_command("execution", "target", Some("preferred"), invocation()).unwrap();
|
|
let preferred = plugins.run_targets(&f.root).into_iter().find(|t| t.target.id == "preferred").unwrap();
|
|
let settings = run_targets::answer(&f.host, &plugins, "main", &preferred.id, true).unwrap();
|
|
assert_eq!(settings.run.default_target, preferred.id);
|
|
assert_eq!(settings.languages.php.run_target, preferred.id);
|
|
assert_eq!(settings.languages.node.run_target, preferred.id);
|
|
assert_eq!(f.host.project("second").unwrap().settings.effective().run.default_target, preferred.id);
|
|
std::fs::write(f.root.join("execution.marker"), "").unwrap();
|
|
std::fs::write(f.root.join("entry.py"), "print('test')\n").unwrap();
|
|
let mut detected =
|
|
plugins.run_detection().detect(&f.root).into_iter().find(|c| c.id == "plugin:execution:detected").unwrap();
|
|
detected.args = vec!["entry.py".into()];
|
|
ide_run::save_user_configs(&f.root, &[detected]).unwrap();
|
|
let mut debug = f.workspace().run_configuration("main", "execution", "detected").unwrap();
|
|
debug.extras.target = None;
|
|
let error = plugins.prepare_debug_run("main", debug).unwrap_err();
|
|
assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput);
|
|
assert!(error.to_string().contains("target"));
|
|
assert!(plugins.run_command("execution", "debug", None, invocation()).unwrap_err().to_string().contains("target"));
|
|
let output = Arc::new(parking_lot::Mutex::new(String::new()));
|
|
let captured = output.clone();
|
|
let config = ide_run::RunConfig {
|
|
id: "php-target".into(),
|
|
name: "PHP target".into(),
|
|
kind: "php".into(),
|
|
command: "php".into(),
|
|
args: vec!["x.php".into(), "a b; $(touch forbidden)".into()],
|
|
cwd: None,
|
|
env: Default::default(),
|
|
detected: false,
|
|
is_test: false,
|
|
debug_adapter: None,
|
|
allow_parallel: false,
|
|
extras: Default::default(),
|
|
};
|
|
let run = ide_host::ops::run::start(
|
|
&f.host,
|
|
"main",
|
|
config.clone(),
|
|
Arc::new(move |chunk| {
|
|
if let ide_run::OutputChunk::Stdout { text } = chunk {
|
|
captured.lock().push_str(&text);
|
|
}
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
for _ in 0..100 {
|
|
if f.host.processes.list().iter().any(|r| r.id == run.id && !r.running) {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
}
|
|
let text = output.lock().clone();
|
|
assert!(text.contains("<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<a b; $(touch forbidden)>"), "{text}");
|
|
assert!(text.contains(&format!("cwd={}", f.root.display())), "{text}");
|
|
assert!(!f.root.join("forbidden").exists());
|
|
// Pin checks are repeated at launch, not only at registration.
|
|
std::fs::write(&tool, "#!/bin/sh\nexit 99\n").unwrap();
|
|
assert!(ide_host::ops::run::start(&f.host, "main", config.clone(), Arc::new(|_| {})).await.is_err());
|
|
plugins.set_capability("execution", "process:execution-tool", false).unwrap();
|
|
assert!(plugins.run_targets(&f.root).is_empty());
|
|
assert!(ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.is_err());
|
|
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
|
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
|
f.host.set_project_trust(&f.root, false, false).await.unwrap();
|
|
assert!(plugins.run_targets(&f.root).is_empty());
|
|
assert!(
|
|
plugins.run_command("execution", "target", None, invocation()).unwrap_err().to_string().contains("trusted")
|
|
);
|
|
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
|
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
|
f.host.close_project_detached("main");
|
|
assert_eq!(plugins.run_targets(&f.root).len(), 1);
|
|
f.host.close_project_detached("second");
|
|
assert!(plugins.run_targets(&f.root).is_empty());
|
|
plugins.shutdown();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn run_target_api15_overrides_and_frozen_api14_registration() {
|
|
use ide_host::plugins::{Consent, PluginHost};
|
|
use ide_plugin::{Invocation, Limits, PluginEngine};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let f = Harness::new();
|
|
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
|
let plugins = Arc::new(
|
|
PluginHost::new(f._tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap(),
|
|
);
|
|
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
|
f.host.set_plugin_host(&plugins);
|
|
let fixtures = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures");
|
|
let engine = PluginEngine::new().unwrap();
|
|
let legacy = engine.compile(&fixtures.join("run-target-api14/dist/plugin.wasm")).unwrap();
|
|
assert_eq!(engine.check_component(&legacy).unwrap().unwrap().to_string(), "1.4.0");
|
|
let tool = f.root.with_file_name("wrapper");
|
|
std::fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
|
|
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let invocation =
|
|
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
|
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
|
|
"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"],
|
|
"env":{}, "detected":false, "isTest":false,
|
|
}))
|
|
.unwrap();
|
|
for (plugin, command) in [("run-target-api14", "target"), ("execution", "target-overrides")] {
|
|
plugins.install_from_dir(&fixtures.join(plugin), &Consent::all()).unwrap();
|
|
assert!(plugins.run_command(plugin, command, Some("empty"), invocation()).is_err());
|
|
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
|
let cases = if plugin == "execution" {
|
|
vec![
|
|
("empty", vec!["composer", "install"]),
|
|
("own", vec!["composer", "--working-dir=/container", "install"]),
|
|
("inherit", vec!["composer", "--cwd=/container", "install"]),
|
|
]
|
|
} else {
|
|
vec![("inherit", vec!["composer", "--cwd=/container", "install"])]
|
|
};
|
|
for (arg, expected) in cases {
|
|
plugins.run_command(plugin, command, Some(arg), invocation()).unwrap();
|
|
let target = plugins.run_targets(&f.root).into_iter().find(|t| t.plugin_id == plugin).unwrap();
|
|
let wrapped = plugins
|
|
.prepare_run_target_launch(
|
|
&f.root,
|
|
&target.id,
|
|
&config,
|
|
ide_plugin::RunTargetOperation::Run,
|
|
&Default::default(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(wrapped.args, expected);
|
|
assert_eq!(PathBuf::from(wrapped.command), tool);
|
|
assert_eq!(wrapped.cwd.unwrap(), f.root);
|
|
let php = ide_run::RunConfig { command: "php".into(), args: vec!["x.php".into()], ..config.clone() };
|
|
assert_eq!(
|
|
plugins
|
|
.prepare_run_target_launch(
|
|
&f.root,
|
|
&target.id,
|
|
&php,
|
|
ide_plugin::RunTargetOperation::Run,
|
|
&Default::default()
|
|
)
|
|
.unwrap()
|
|
.args,
|
|
["wrapped", "--cwd=/container", "php", "x.php"]
|
|
);
|
|
}
|
|
if plugin == "execution" {
|
|
assert!(plugins.run_command(plugin, command, Some("invalid"), invocation()).is_err());
|
|
// A failed replacement retains the previous valid registration.
|
|
assert_eq!(plugins.run_targets(&f.root).iter().filter(|t| t.plugin_id == plugin).count(), 1);
|
|
}
|
|
plugins.set_capability(plugin, "process:execution-tool", false).unwrap();
|
|
assert!(plugins.run_targets(&f.root).iter().all(|t| t.plugin_id != plugin));
|
|
assert!(plugins.run_command(plugin, command, None, invocation()).is_err());
|
|
plugins.choose_program(plugin, "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
|
f.host.set_project_trust(&f.root, false, false).await.unwrap();
|
|
assert!(plugins.run_command(plugin, command, None, invocation()).unwrap_err().to_string().contains("trusted"));
|
|
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
|
}
|
|
plugins.shutdown();
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn run_target_validation_gates_launches_and_preserves_selection() {
|
|
use ide_host::plugins::{Consent, PluginHost};
|
|
use ide_plugin::{Invocation, Limits, RunTargetOperation};
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::time::Duration;
|
|
let f = Harness::new();
|
|
f.host.set_project_trust(&f.root, true, false).await.unwrap();
|
|
let plugins = Arc::new(
|
|
PluginHost::new(
|
|
f._tmp.path().join("plugins"),
|
|
Arc::new(|_: Option<&str>, _| {}),
|
|
Limits {
|
|
timeout: Duration::from_millis(200),
|
|
idle_timeout: Some(Duration::from_millis(20)),
|
|
restart_backoff: Duration::ZERO,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap(),
|
|
);
|
|
plugins.set_workspace(Some(Arc::new(f.workspace())));
|
|
f.host.set_plugin_host(&plugins);
|
|
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
|
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
|
|
let tool = f.root.with_file_name("wrapper");
|
|
std::fs::write(&tool, "#!/bin/sh\ntouch spawned\n").unwrap();
|
|
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
|
|
let invocation =
|
|
|| Invocation { client: Some("main".into()), workspace_root: Some(f.root.clone()), ..Default::default() };
|
|
let config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
|
|
"id":"composer", "name":"Composer", "kind":"shell", "command":"/tools/composer", "args":["install"],
|
|
"env":{"SECRET":"not sent"}, "detected":false, "isTest":false,
|
|
}))
|
|
.unwrap();
|
|
for id in ["allow", "refuse", "long-refusal", "timeout", "trap"] {
|
|
plugins.run_command("execution", "target", Some(id), invocation()).unwrap();
|
|
}
|
|
let targets = plugins.run_targets(&f.root);
|
|
let id = |name: &str| targets.iter().find(|t| t.target.id == name).unwrap().id.clone();
|
|
for operation in [RunTargetOperation::Run, RunTargetOperation::Test] {
|
|
plugins.prepare_run_target_launch(&f.root, &id("allow"), &config, operation, &Default::default()).unwrap();
|
|
let snapshot = plugins.run_command("execution", "validation", None, invocation()).unwrap().unwrap();
|
|
assert_eq!(snapshot, format!("{}|allow|{operation:?}|composer|{}", f.root.display(), f.root.display()));
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
|
plugins.tick();
|
|
assert_eq!(plugins.info_of("execution").unwrap().status, ide_host::plugins::PluginStatus::Inactive);
|
|
plugins
|
|
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &Default::default())
|
|
.unwrap();
|
|
assert!(plugins.info_of("execution").unwrap().stats.reclaims > 0);
|
|
let selected = id("refuse");
|
|
ide_host::plugins::run_targets::answer(&f.host, &plugins, "main", &selected, true).unwrap();
|
|
for is_test in [false, true] {
|
|
let mut config = config.clone();
|
|
config.is_test = is_test;
|
|
config.extras.target = Some(selected.clone());
|
|
let error = ide_host::ops::run::start(&f.host, "main", config, Arc::new(|_| {})).await.unwrap_err();
|
|
assert!(error.message.contains("Plugin execution tests: Project runtime is stopped"), "{error}");
|
|
assert!(f.host.processes.list().is_empty());
|
|
assert!(!f.root.join("spawned").exists());
|
|
assert_eq!(f.host.project("main").unwrap().settings.effective().run.default_target, selected);
|
|
assert_eq!(plugins.run_targets(&f.root).len(), targets.len());
|
|
}
|
|
let error = plugins
|
|
.prepare_run_target_launch(&f.root, &id("long-refusal"), &config, RunTargetOperation::Run, &Default::default())
|
|
.unwrap_err();
|
|
assert!(error.message.len() < 4200);
|
|
let request = ide_core::RequestId(2408);
|
|
let mut cfg = config.clone();
|
|
cfg.extras.target = Some(id("timeout"));
|
|
let worker = f.host.clone();
|
|
let pending = tokio::spawn(async move {
|
|
ide_host::ops::run::start_request(&worker, "main", cfg, Arc::new(|_| {}), Some(request)).await
|
|
});
|
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
|
f.host.cancel_request(request);
|
|
assert_eq!(pending.await.unwrap().unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
|
for name in ["timeout", "trap"] {
|
|
let started = std::time::Instant::now();
|
|
let error = plugins
|
|
.prepare_run_target_launch(&f.root, &id(name), &config, RunTargetOperation::Run, &Default::default())
|
|
.unwrap_err();
|
|
assert!(error.message.contains("Plugin execution tests"), "{error}");
|
|
if name == "timeout" {
|
|
assert!(error.message.contains("timed out"), "{error}");
|
|
assert!(started.elapsed() < Duration::from_secs(2), "background tasks must not extend validation");
|
|
}
|
|
assert!(f.host.processes.list().is_empty());
|
|
}
|
|
// A cancelled launch never enters the wrapper.
|
|
let cancel = ide_core::CancellationToken::new();
|
|
cancel.cancel();
|
|
assert_eq!(
|
|
plugins
|
|
.prepare_run_target_launch(&f.root, &id("allow"), &config, RunTargetOperation::Run, &cancel)
|
|
.unwrap_err()
|
|
.kind,
|
|
ide_core::error::ErrorKind::Cancelled
|
|
);
|
|
plugins.shutdown();
|
|
}
|