Skip to main content

aether_lspd/
testing.rs

1#![doc = include_str!("docs/testing.md")]
2
3use std::future::{Future, poll_fn};
4use std::path::{Path, PathBuf};
5use std::pin::pin;
6use std::process::Command;
7use std::sync::{Arc, mpsc};
8use std::thread::JoinHandle;
9use std::time::Duration;
10use tokio::sync::{oneshot, watch};
11
12use crate::LanguageId;
13use crate::daemon::{LspDaemon, Timeouts};
14use crate::error::DaemonError;
15use crate::socket_path::socket_path;
16use crate::uri::path_to_uri;
17pub use utils::testing::TestWorkspace;
18
19/// An in-process daemon with explicit configuration and deterministic shutdown.
20pub struct TestDaemon {
21    shutdown_tx: Option<oneshot::Sender<()>>,
22    task: Option<JoinHandle<Result<(), DaemonError>>>,
23    clock: Option<tokio::runtime::Handle>,
24}
25
26/// Configures a [`TestDaemon`] before it starts. Unset timeouts match the production daemon.
27pub struct TestDaemonBuilder {
28    workspace_root: PathBuf,
29    language: LanguageId,
30    timeouts: Timeouts,
31    hooks: TestHooks,
32    paused_time: bool,
33}
34
35impl TestDaemon {
36    pub fn spawn(workspace_root: &Path, language: LanguageId) -> Result<Self, TestDaemonError> {
37        Self::builder(workspace_root, language).spawn()
38    }
39
40    pub fn builder(workspace_root: &Path, language: LanguageId) -> TestDaemonBuilder {
41        TestDaemonBuilder {
42            workspace_root: workspace_root.to_path_buf(),
43            language,
44            timeouts: Timeouts::default(),
45            hooks: TestHooks::default(),
46            paused_time: false,
47        }
48    }
49
50    pub async fn advance_time(&self, duration: Duration) {
51        self.clock
52            .as_ref()
53            .expect("enable paused_time on the daemon builder")
54            .spawn(async move { tokio::time::advance(duration).await })
55            .await
56            .expect("daemon clock task should complete");
57    }
58
59    pub fn shutdown(mut self) -> Result<(), TestDaemonError> {
60        if let Some(shutdown_tx) = self.shutdown_tx.take() {
61            let _ = shutdown_tx.send(());
62        }
63        self.finish()
64    }
65
66    fn finish(&mut self) -> Result<(), TestDaemonError> {
67        let task = self.task.take().expect("test daemon task should be present");
68        task.join().map_err(|_| TestDaemonError::ThreadPanicked)??;
69        Ok(())
70    }
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub enum TestPoint {
75    ReadyWait,
76    RequestReceived(String),
77    DocumentOpen(lsp_types::Uri),
78    DocumentSyncWait(lsp_types::Uri),
79    DiagnosticsWait(lsp_types::Uri),
80}
81
82#[derive(Clone, Default)]
83pub struct TestGate {
84    state: Arc<watch::Sender<(bool, bool)>>,
85}
86
87impl TestGate {
88    pub async fn reached(&self) {
89        let _ = self.state.subscribe().wait_for(|(reached, _)| *reached).await;
90    }
91
92    pub fn release(&self) {
93        self.state.send_modify(|(_, released)| *released = true);
94    }
95
96    fn reach(&self) {
97        self.state.send_modify(|(reached, _)| *reached = true);
98    }
99
100    async fn pause(&self) {
101        self.reach();
102        let _ = self.state.subscribe().wait_for(|(_, released)| *released).await;
103    }
104}
105
106impl TestDaemonBuilder {
107    pub fn gate(mut self, point: TestPoint, gate: &TestGate) -> Self {
108        self.hooks.0.push((point, gate.clone()));
109        self
110    }
111
112    pub fn paused_time(mut self) -> Self {
113        self.paused_time = true;
114        self
115    }
116
117    pub fn request_timeout(mut self, timeout: Duration) -> Self {
118        self.timeouts.request = timeout;
119        self
120    }
121
122    pub fn diagnostics_timeout(mut self, timeout: Duration) -> Self {
123        self.timeouts.diagnostics = timeout;
124        self
125    }
126
127    pub fn diagnostics_settle(mut self, settle: Duration) -> Self {
128        self.timeouts.diagnostics_settle = settle;
129        self
130    }
131
132    pub fn spawn(self) -> Result<TestDaemon, TestDaemonError> {
133        let socket_path = socket_path(&self.workspace_root, self.language);
134        let daemon = LspDaemon::bind(socket_path, None, self.timeouts)?.with_test_hooks(self.hooks);
135        let runtime = if self.paused_time {
136            tokio::runtime::Builder::new_current_thread().enable_all().start_paused(true).build()
137        } else {
138            tokio::runtime::Builder::new_multi_thread().enable_all().build()
139        }
140        .map_err(TestDaemonError::Runtime)?;
141        let clock = self.paused_time.then(|| runtime.handle().clone());
142        let (shutdown_tx, shutdown_rx) = oneshot::channel();
143        let task = std::thread::spawn(move || {
144            // A pending blocking task disables Tokio's automatic clock advancement during external I/O.
145            let (release_tx, release_rx) = mpsc::channel::<()>();
146            let hold_clock = self.paused_time.then(|| runtime.spawn_blocking(move || release_rx.recv()));
147            let result = runtime.block_on(daemon.run_until_shutdown(shutdown_rx));
148            drop(release_tx);
149            if let Some(hold_clock) = hold_clock {
150                runtime.block_on(hold_clock).expect("clock guard should finish").ok();
151            }
152            result
153        });
154        Ok(TestDaemon { shutdown_tx: Some(shutdown_tx), task: Some(task), clock })
155    }
156}
157
158impl Drop for TestDaemon {
159    fn drop(&mut self) {
160        if let Some(shutdown_tx) = self.shutdown_tx.take() {
161            let _ = shutdown_tx.send(());
162        }
163        if let Some(task) = self.task.take() {
164            let _ = task.join();
165        }
166    }
167}
168
169#[derive(Debug, thiserror::Error)]
170pub enum TestDaemonError {
171    #[error("Daemon failed: {0}")]
172    Daemon(#[from] DaemonError),
173    #[error("Failed to create daemon runtime: {0}")]
174    Runtime(std::io::Error),
175    #[error("Daemon thread panicked")]
176    ThreadPanicked,
177}
178
179/// Configure one language to use the shared fake Python server in the current test process.
180///
181/// # Safety
182///
183/// This mutates process-global environment variables. Call it only from an isolated test binary
184/// before starting any daemon or other thread that can read language-server configuration.
185pub unsafe fn configure_fake_server(language: LanguageId, extra_args: &[&str]) {
186    let env_key = language.server().expect("language should have a configured server").env_key;
187    let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/common/fake_lsp_server.py");
188    let mut args = vec![script.to_string_lossy().into_owned()];
189    args.extend(extra_args.iter().map(ToString::to_string));
190
191    unsafe {
192        std::env::set_var(format!("AETHER_LSPD_SERVER_COMMAND_{env_key}"), "python3");
193        std::env::set_var(
194            format!("AETHER_LSPD_SERVER_ARGS_{env_key}"),
195            serde_json::to_string(&args).expect("fake server arguments should serialize"),
196        );
197    }
198}
199
200pub fn cargo_project(name: &str) -> TestWorkspace {
201    let manifest = format!(
202        r#"[package]
203name = "{name}"
204version = "0.1.0"
205edition = "2021"
206"#
207    );
208    let main = r#"fn main() {
209    println!("Hello, world!");
210}
211"#;
212    TestWorkspace::new().file("Cargo.toml", manifest).file("src/main.rs", main)
213}
214
215pub fn node_project(name: &str) -> TestWorkspace {
216    let package = format!(
217        r#"{{
218  "name": "{name}",
219  "version": "0.1.0"
220}}"#
221    );
222    let tsconfig = r#"{
223  "compilerOptions": {
224    "strict": true,
225    "noEmit": true
226  }
227}"#;
228    let project =
229        TestWorkspace::new().file("package.json", package).file("tsconfig.json", tsconfig).file("src/index.ts", "");
230
231    let args = ["install", "--save-dev", "--no-audit", "--no-fund", "--prefer-offline", TYPESCRIPT_PACKAGE];
232    let output = Command::new("npm").args(args).current_dir(project.root()).output().expect("failed to run npm");
233    assert!(output.status.success(), "npm {} failed:\n{}", args.join(" "), String::from_utf8_lossy(&output.stderr));
234    project
235}
236
237pub fn file_uri(path: &Path) -> lsp_types::Uri {
238    path_to_uri(path).expect("Invalid file path")
239}
240
241#[derive(Clone, Default)]
242pub(crate) struct TestHooks(Vec<(TestPoint, TestGate)>);
243
244impl TestHooks {
245    pub(crate) async fn pause(&self, point: TestPoint) {
246        if let Some(gate) = self.gate(&point) {
247            gate.pause().await;
248        }
249    }
250
251    pub(crate) async fn observe_pending<T>(&self, point: TestPoint, future: impl Future<Output = T>) -> T {
252        let mut future = pin!(future);
253        poll_fn(|cx| {
254            let result = future.as_mut().poll(cx);
255            if result.is_pending()
256                && let Some(gate) = self.gate(&point)
257            {
258                gate.reach();
259            }
260            result
261        })
262        .await
263    }
264
265    fn gate(&self, point: &TestPoint) -> Option<&TestGate> {
266        self.0.iter().find_map(|(at, gate)| (at == point).then_some(gate))
267    }
268}
269
270const TYPESCRIPT_PACKAGE: &str = "typescript@7.0.2";