mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
A diagnostics test assumed a cold plugin actor would enter its provider within 10 ms, which failed on slower CI. The fixture is prewarmed and uses synchronous entry gates, so the tests tell a skipped request from a provider that started and hold the provider past the unchanged deadline; adds a test that cancelling a running provider doesn't affect another window's command. Co-authored-by: arnaud-ritti <[email protected]>
433 lines
20 KiB
Rust
433 lines
20 KiB
Rust
//! Real Wasm provider dispatch and the document-edit path used locally and over RPC.
|
|
use ide_core::{CancellationToken, ChangeSet, Diagnostic, IdeEvent, Pos, Range, TextChange};
|
|
use ide_host::plugins::{Consent, HostWorkspace, PluginEvent, PluginHost, set_editor_providers};
|
|
use ide_host::{Host, HostConfig, ops};
|
|
use ide_plugin::{Invocation, Limits, ProviderDocument};
|
|
use parking_lot::Mutex;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, mpsc};
|
|
use std::time::{Duration, Instant};
|
|
|
|
const ID: &str = "test.diagnostics";
|
|
const SOURCE: &str = "plugin-provider:test.diagnostics";
|
|
|
|
fn fixture() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/diagnostics")
|
|
}
|
|
|
|
fn plugins(dir: &Path, limits: Limits) -> Arc<PluginHost> {
|
|
let host = Arc::new(PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap());
|
|
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
|
host
|
|
}
|
|
|
|
fn generous() -> Limits {
|
|
Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() }
|
|
}
|
|
|
|
fn doc(root: &Path, language: &str, text: &str) -> ProviderDocument {
|
|
ProviderDocument { path: root.join("a.txt"), language: language.into(), version: 42, text: text.into() }
|
|
}
|
|
|
|
#[test]
|
|
fn declared_languages_receive_versioned_text_and_utf16_ranges() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let host = plugins(tmp.path(), generous());
|
|
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "😀BAD\nBAD"), &CancellationToken::new());
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].0, ID);
|
|
let items = &result[0].1;
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].range, Range::new(Pos::new(0, 2), Pos::new(0, 5)));
|
|
assert_eq!(items[1].range, Range::new(Pos::new(1, 0), Pos::new(1, 3)));
|
|
assert_eq!(items[0].message, "BAD in text at version 42");
|
|
assert_eq!(items[0].code.as_deref(), Some("bad-word"));
|
|
assert_eq!(items[0].server.as_deref(), Some(ID));
|
|
assert!(host.provide_diagnostics(&doc(tmp.path(), "rust", "BAD"), &CancellationToken::new()).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn provider_diagnostics_have_the_same_bounds_and_tags_as_manual_publish() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let host = plugins(tmp.path(), generous());
|
|
let result = host.provide_diagnostics(&doc(tmp.path(), "text", "limits"), &CancellationToken::new());
|
|
let items = &result[0].1;
|
|
assert_eq!(items.len(), 5000);
|
|
assert_eq!(items[0].message.len(), 4096);
|
|
assert_eq!(items[0].source.as_ref().unwrap().len(), 128);
|
|
assert_eq!(items[0].code.as_ref().unwrap().len(), 128);
|
|
assert!(items[0].unnecessary && items[0].deprecated);
|
|
}
|
|
|
|
#[test]
|
|
fn the_export_does_not_need_project_read_permission() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let package = tmp.path().join("no-capability");
|
|
std::fs::create_dir_all(package.join("dist")).unwrap();
|
|
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
|
|
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
|
std::fs::write(package.join("plugin.toml"), manifest.replace("[capabilities]\nrequired = [\"project.read\"]", ""))
|
|
.unwrap();
|
|
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
|
|
host.install_from_dir(&package, &Consent::default()).unwrap();
|
|
assert_eq!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).len(), 1);
|
|
assert!(host.run_command(ID, "publish", Some("/project/a.txt"), Invocation::default()).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn diagnostics_is_an_api_1_3_addition() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let package = tmp.path().join("old");
|
|
std::fs::create_dir_all(package.join("dist")).unwrap();
|
|
std::fs::copy(fixture().join("dist/plugin.wasm"), package.join("dist/plugin.wasm")).unwrap();
|
|
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
|
std::fs::write(package.join("plugin.toml"), manifest.replace("^1.3", "^1.0")).unwrap();
|
|
let host = PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), generous()).unwrap();
|
|
assert!(host.install_from_dir(&package, &Consent::all()).unwrap_err().message.contains("new in plugin API 1.3"));
|
|
}
|
|
|
|
// The fixture signals from inside a host import, so the actor cannot finish
|
|
// until this test releases it. A dropped guard also releases a failing test.
|
|
enum FixtureEvent {
|
|
Entered,
|
|
Returned,
|
|
}
|
|
|
|
struct FixtureGate {
|
|
events: mpsc::Receiver<FixtureEvent>,
|
|
finished: mpsc::Sender<FixtureEvent>,
|
|
release: mpsc::Sender<()>,
|
|
}
|
|
|
|
impl FixtureGate {
|
|
fn new(dir: &Path, limits: Limits) -> (Arc<PluginHost>, Self) {
|
|
let (started, events) = mpsc::channel();
|
|
let finished = started.clone();
|
|
let (release, released) = mpsc::channel();
|
|
let released = Mutex::new(released);
|
|
let sink = Arc::new(move |_: Option<&str>, event| {
|
|
if matches!(event, PluginEvent::ContextChanged { ref plugin_id, ref key, value: Some(_), .. } if plugin_id == ID && key == "fixture-gate")
|
|
{
|
|
started.send(FixtureEvent::Entered).unwrap();
|
|
released.lock().recv_timeout(Duration::from_secs(5)).unwrap();
|
|
}
|
|
});
|
|
let host = Arc::new(PluginHost::new(dir.join("plugins"), sink, limits).unwrap());
|
|
host.install_from_dir(&fixture(), &Consent::all()).unwrap();
|
|
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
|
|
(host, Self { events, finished, release })
|
|
}
|
|
|
|
fn entered(&self) {
|
|
assert!(matches!(self.events.recv_timeout(Duration::from_secs(5)).unwrap(), FixtureEvent::Entered));
|
|
}
|
|
|
|
fn provider(
|
|
&self,
|
|
host: &Arc<PluginHost>,
|
|
document: ProviderDocument,
|
|
cancel: &CancellationToken,
|
|
) -> std::thread::JoinHandle<Vec<(String, Vec<Diagnostic>)>> {
|
|
let start = Instant::now();
|
|
loop {
|
|
let (host, document, cancel, finished) =
|
|
(host.clone(), document.clone(), cancel.clone(), self.finished.clone());
|
|
let call = std::thread::spawn(move || {
|
|
let result = host.provide_diagnostics(&document, &cancel);
|
|
let _ = finished.send(FixtureEvent::Returned);
|
|
result
|
|
});
|
|
if matches!(self.events.recv_timeout(Duration::from_secs(5)).unwrap(), FixtureEvent::Entered) {
|
|
return call;
|
|
}
|
|
// Expiry before entry is valid, but cannot exercise a late answer.
|
|
assert!(call.join().unwrap().is_empty());
|
|
assert!(start.elapsed() < Duration::from_secs(5), "provider never entered the fixture");
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for FixtureGate {
|
|
fn drop(&mut self) {
|
|
let _ = self.release.send(());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn late_and_cancelled_answers_are_dropped_without_disabling_the_plugin() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let soft = Duration::from_millis(10);
|
|
let (host, gate) = FixtureGate::new(tmp.path(), Limits { edit_soft: soft, ..generous() });
|
|
let call = gate.provider(&host, doc(tmp.path(), "text", "gate BAD"), &CancellationToken::new());
|
|
// Entry was observed: hold this actual provider beyond its soft deadline.
|
|
assert!(matches!(gate.events.recv_timeout(soft), Err(mpsc::RecvTimeoutError::Timeout)));
|
|
drop(gate);
|
|
assert!(call.join().unwrap().is_empty());
|
|
let cancel = CancellationToken::new();
|
|
cancel.cancel();
|
|
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
|
|
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
|
|
}
|
|
|
|
#[test]
|
|
fn cancelling_a_started_provider_drops_its_answer_without_disabling_the_plugin() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (host, gate) = FixtureGate::new(tmp.path(), generous());
|
|
let cancel = CancellationToken::new();
|
|
let call = gate.provider(&host, doc(tmp.path(), "text", "gate BAD"), &cancel);
|
|
cancel.cancel();
|
|
drop(gate);
|
|
assert!(call.join().unwrap().is_empty());
|
|
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("1"));
|
|
}
|
|
|
|
#[test]
|
|
fn cancelling_a_queued_provider_does_not_interrupt_another_windows_command() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (host, gate) = FixtureGate::new(tmp.path(), generous());
|
|
let waiter = {
|
|
let host = host.clone();
|
|
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
|
|
};
|
|
gate.entered();
|
|
let cancel = CancellationToken::new();
|
|
let canceller = {
|
|
let cancel = cancel.clone();
|
|
std::thread::spawn(move || {
|
|
std::thread::sleep(Duration::from_millis(30));
|
|
cancel.cancel();
|
|
})
|
|
};
|
|
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &cancel).is_empty());
|
|
canceller.join().unwrap();
|
|
drop(gate);
|
|
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
|
|
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
|
|
}
|
|
|
|
#[test]
|
|
fn editor_requests_expire_in_the_queue_without_running_after_a_command() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (host, gate) = FixtureGate::new(tmp.path(), Limits { edit_soft: Duration::from_millis(10), ..generous() });
|
|
let waiter = {
|
|
let host = host.clone();
|
|
std::thread::spawn(move || host.run_command(ID, "wait", None, Invocation::default()))
|
|
};
|
|
gate.entered();
|
|
assert!(host.provide_diagnostics(&doc(tmp.path(), "text", "BAD"), &CancellationToken::new()).is_empty());
|
|
drop(gate);
|
|
assert_eq!(waiter.join().unwrap().unwrap().as_deref(), Some("finished"));
|
|
assert_eq!(host.run_command(ID, "calls", None, Invocation::default()).unwrap().as_deref(), Some("0"));
|
|
assert_eq!(host.info_of(ID).unwrap().stats.slow_calls, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn typing_on_cancels_a_running_provider_before_its_hard_deadline() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let (host, gate) = FixtureGate::new(tmp.path(), Limits { fuel: u64::MAX, ..generous() });
|
|
let cancel = CancellationToken::new();
|
|
let call = gate.provider(&host, doc(tmp.path(), "text", "gate spin BAD"), &cancel);
|
|
let started = Instant::now();
|
|
cancel.cancel();
|
|
drop(gate);
|
|
assert!(call.join().unwrap().is_empty());
|
|
assert!(started.elapsed() < Duration::from_secs(2));
|
|
}
|
|
|
|
// This executable has one edit-path test because editor providers are installed once per process.
|
|
type Events = Arc<Mutex<Vec<(String, IdeEvent)>>>;
|
|
|
|
fn runtime_host(base: &Path, events: Events) -> Arc<Host> {
|
|
Arc::new(Host::new(HostConfig {
|
|
config_dir: base.join("config"),
|
|
data_dir: base.join("data"),
|
|
local_data_dir: base.join("data"),
|
|
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
|
syntax: Arc::default(),
|
|
sink_for: Box::new(move |window| {
|
|
let (events, window) = (events.clone(), window.to_owned());
|
|
Arc::new(move |event: IdeEvent| events.lock().push((window.clone(), event)))
|
|
}),
|
|
owner: |w| w,
|
|
runtime: tokio::runtime::Handle::current(),
|
|
project_environment: false,
|
|
}))
|
|
}
|
|
|
|
fn latest(events: &Events, window: &str, source: &str) -> Option<Vec<Diagnostic>> {
|
|
events.lock().iter().rev().find_map(|(w, e)| match e {
|
|
IdeEvent::Diagnostics { source: s, items, .. } if w == window && s == source => Some(items.clone()),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
async fn wait_for(events: &Events, window: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
|
|
wait_for_source(events, window, SOURCE, predicate).await;
|
|
}
|
|
|
|
async fn wait_for_source(events: &Events, window: &str, source: &str, predicate: impl Fn(&[Diagnostic]) -> bool) {
|
|
let start = Instant::now();
|
|
loop {
|
|
if latest(events, window, source).is_some_and(|items| predicate(&items)) {
|
|
return;
|
|
}
|
|
assert!(start.elapsed() < Duration::from_secs(5), "missing diagnostics for {window}");
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
}
|
|
|
|
fn replace(host: &Host, window: &str, info: &ide_workspace::DocumentInfo, text: &str) -> u32 {
|
|
let project = host.project(window).unwrap();
|
|
let snapshot = project.documents.snapshot(info.id).unwrap();
|
|
let len = snapshot.text.encode_utf16().count() as u32;
|
|
ops::doc::edit(
|
|
host,
|
|
window,
|
|
info.id,
|
|
snapshot.version,
|
|
ChangeSet { changes: vec![TextChange { from: 0, to: len, insert: text.into() }] },
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn typing_is_debounced_versioned_and_scoped_to_document_project_and_window_lifetimes() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let root = dunce::canonicalize(tmp.path()).unwrap();
|
|
let path = root.join("a.txt");
|
|
std::fs::write(&path, "clean").unwrap();
|
|
let events: Events = Arc::default();
|
|
let host = runtime_host(&root.join("app"), events.clone());
|
|
let plugins = plugins(&root, generous());
|
|
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
|
set_editor_providers(Some(&plugins));
|
|
let left = host.open_light("left", &path).unwrap();
|
|
host.open_light("right", &path).unwrap();
|
|
let a = ops::doc::open(&host, "left", path.clone()).await.unwrap();
|
|
let b = ops::doc::open(&host, "right", path.clone()).await.unwrap();
|
|
let second_tab = ops::doc::open(&host, "right", path.clone()).await.unwrap();
|
|
assert_eq!(b.id, second_tab.id);
|
|
wait_for(&events, "left", |items| items.is_empty()).await;
|
|
wait_for(&events, "right", |items| items.is_empty()).await;
|
|
|
|
let calls: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
|
|
events.lock().clear();
|
|
replace(&host, "left", &a, "BAD");
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
let version = replace(&host, "left", &a, "😀BAD");
|
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
|
assert!(latest(&events, "left", SOURCE).is_none(), "300ms after the latest edit");
|
|
wait_for(&events, "left", |items| !items.is_empty()).await;
|
|
assert_eq!(
|
|
latest(&events, "left", SOURCE).unwrap()[0].message,
|
|
format!("BAD in {} at version {version}", a.language)
|
|
);
|
|
assert!(latest(&events, "right", SOURCE).is_none(), "other window has its own buffer");
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "clean", "no save required");
|
|
let after: u64 = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap().unwrap().parse().unwrap();
|
|
assert_eq!(after, calls + 1, "the edit burst was coalesced");
|
|
|
|
plugins.run_command(ID, "publish", path.to_str(), Invocation::default()).unwrap();
|
|
let manual_window = events.lock().iter().find_map(|(window, event)| matches!(event, IdeEvent::Diagnostics { source, items, .. } if source == "plugin:test.diagnostics" && !items.is_empty()).then(|| window.clone())).unwrap();
|
|
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
|
|
replace(&host, "left", &a, "clean again");
|
|
wait_for(&events, "left", |items| items.is_empty()).await;
|
|
assert_eq!(latest(&events, &manual_window, "plugin:test.diagnostics").unwrap()[0].message, "Manual problem");
|
|
|
|
events.lock().clear();
|
|
replace(&host, "left", &a, "slow BAD");
|
|
tokio::time::sleep(Duration::from_millis(350)).await;
|
|
replace(&host, "left", &a, "latest clean");
|
|
wait_for(&events, "left", |items| items.is_empty()).await;
|
|
assert!(
|
|
!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())),
|
|
"superseded in-flight answers never publish"
|
|
);
|
|
|
|
replace(&host, "right", &b, "BAD right");
|
|
wait_for(&events, "right", |items| !items.is_empty()).await;
|
|
ops::doc::close(&host, "right", b.id).unwrap();
|
|
assert!(!latest(&events, "right", SOURCE).unwrap().is_empty(), "another pane still owns this document");
|
|
ops::doc::close(&host, "right", second_tab.id).unwrap();
|
|
wait_for(&events, "right", |items| items.is_empty()).await;
|
|
replace(&host, "left", &a, "BAD left");
|
|
wait_for(&events, "left", |items| !items.is_empty()).await;
|
|
plugins.set_enabled(ID, false).unwrap();
|
|
wait_for(&events, "left", |items| items.is_empty()).await;
|
|
plugins.set_enabled(ID, true).unwrap();
|
|
|
|
events.lock().clear();
|
|
replace(&host, "left", &a, "slow BAD closing");
|
|
tokio::time::sleep(Duration::from_millis(350)).await;
|
|
host.close_project("left");
|
|
// Keep the old Project alive deliberately: window reuse must still cancel it.
|
|
assert_eq!(left.documents.snapshot(a.id).unwrap().text, "slow BAD closing");
|
|
host.open_light("left", &path).unwrap();
|
|
let reopened = ops::doc::open(&host, "left", path.clone()).await.unwrap();
|
|
wait_for(&events, "left", |items| items.is_empty()).await;
|
|
assert!(!events.lock().iter().any(|(_, e)| matches!(e, IdeEvent::Diagnostics { items, .. } if !items.is_empty())));
|
|
// Project plugins receive only snapshots from the project that owns their declaration.
|
|
let project_package = root.join(ide_host::plugins::DEV_PLUGINS_DIR).join("diagnostics");
|
|
std::fs::create_dir_all(project_package.join("dist")).unwrap();
|
|
std::fs::copy(fixture().join("dist/plugin.wasm"), project_package.join("dist/plugin.wasm")).unwrap();
|
|
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
|
std::fs::write(project_package.join("plugin.toml"), manifest.replace(ID, "project.diagnostics")).unwrap();
|
|
plugins.set_project_policy(Arc::new(|_| true));
|
|
plugins.sync_project(&root);
|
|
plugins.grant("project.diagnostics", &Consent::all()).unwrap();
|
|
replace(&host, "left", &reopened, "BAD in project");
|
|
wait_for(&events, "left", |items| !items.is_empty()).await;
|
|
let project_source = "plugin-provider:project.diagnostics";
|
|
wait_for_source(&events, "left", project_source, |items| !items.is_empty()).await;
|
|
let elsewhere = root.join("other");
|
|
std::fs::create_dir_all(&elsewhere).unwrap();
|
|
let other_path = elsewhere.join("other.txt");
|
|
std::fs::write(&other_path, "BAD outside").unwrap();
|
|
host.open_light("elsewhere", &other_path).unwrap();
|
|
ops::doc::open(&host, "elsewhere", other_path).await.unwrap();
|
|
wait_for(&events, "elsewhere", |items| !items.is_empty()).await;
|
|
assert!(latest(&events, "elsewhere", project_source).is_none());
|
|
plugins.restrict_project(&root);
|
|
assert!(latest(&events, "left", project_source).unwrap().is_empty());
|
|
// A completed answer may still be waiting for a sibling when its plugin crashes.
|
|
let sibling = root.join("slow-sibling");
|
|
std::fs::create_dir_all(sibling.join("dist")).unwrap();
|
|
std::fs::copy(fixture().join("dist/plugin.wasm"), sibling.join("dist/plugin.wasm")).unwrap();
|
|
let manifest = std::fs::read_to_string(fixture().join("plugin.toml")).unwrap();
|
|
std::fs::write(
|
|
sibling.join("plugin.toml"),
|
|
manifest.replace(ID, "test.slow").replace("[\"text\", \"plaintext\"]", "[\"plaintext\"]"),
|
|
)
|
|
.unwrap();
|
|
plugins.install_from_dir(&sibling, &Consent::all()).unwrap();
|
|
plugins.run_command("test.slow", "calls", None, Invocation::default()).unwrap();
|
|
let calls = plugins.run_command(ID, "calls", None, Invocation::default()).unwrap();
|
|
events.lock().clear();
|
|
replace(&host, "left", &reopened, "BAD completed before crash");
|
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
let waiter = {
|
|
let plugins = plugins.clone();
|
|
std::thread::spawn(move || plugins.run_command("test.slow", "wait", None, Invocation::default()))
|
|
};
|
|
let started = Instant::now();
|
|
while plugins.run_command(ID, "calls", None, Invocation::default()).unwrap() == calls {
|
|
assert!(started.elapsed() < Duration::from_secs(5));
|
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
|
}
|
|
assert!(
|
|
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
|
|
"the sibling still holds the result: {:?}",
|
|
latest(&events, "left", SOURCE)
|
|
);
|
|
assert!(plugins.provide_diagnostics(&doc(&root, "text", "panic"), &CancellationToken::new()).is_empty());
|
|
waiter.join().unwrap().unwrap();
|
|
wait_for_source(&events, "left", "plugin-provider:test.slow", |items| !items.is_empty()).await;
|
|
assert!(
|
|
latest(&events, "left", SOURCE).is_none_or(|items| items.is_empty()),
|
|
"a crash invalidates completed answers"
|
|
);
|
|
ops::doc::close(&host, "left", reopened.id).unwrap();
|
|
host.shutdown().await;
|
|
set_editor_providers(None);
|
|
}
|