Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make testing more reliable and realistic #87

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/air/src/commands/lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::ExitStatus;
#[tokio::main]
pub(crate) async fn lsp(_command: LspCommand) -> anyhow::Result<ExitStatus> {
// Returns after shutdown
lsp::start_lsp(tokio::io::stdin(), tokio::io::stdout()).await;
lsp::start_server(tokio::io::stdin(), tokio::io::stdout()).await;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Happy to rename this back but i got a little confused seeing TestClient accept a lambda function that called start_lsp(). I thought start_lsp() did something client related when I saw that.


Ok(ExitStatus::Success)
}
48 changes: 0 additions & 48 deletions crates/lsp/src/handlers_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,51 +40,3 @@ pub(crate) fn document_formatting(
let edits = to_proto::replace_all_edit(&doc.line_index, &doc.contents, &output)?;
Ok(Some(edits))
}

#[cfg(test)]
mod tests {
use crate::{
documents::Document, tower_lsp::init_test_client, tower_lsp_test_client::TestClientExt,
};

#[tests_macros::lsp_test]
async fn test_format() {
let mut client = init_test_client().await;

#[rustfmt::skip]
let doc = Document::doodle(
"
1
2+2
3 + 3 +
3",
);

let formatted = client.format_document(&doc).await;
insta::assert_snapshot!(formatted);

client
}

// https://github.com/posit-dev/air/issues/61
#[tests_macros::lsp_test]
async fn test_format_minimal_diff() {
let mut client = init_test_client().await;

#[rustfmt::skip]
let doc = Document::doodle(
"1
2+2
3
",
);

let edits = client.format_document_edits(&doc).await.unwrap();
assert!(edits.len() == 1);

let edit = &edits[0];
assert_eq!(edit.new_text, " + ");

client
}
}
11 changes: 8 additions & 3 deletions crates/lsp/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// TODO: Remove this
#![allow(dead_code)]

pub use tower_lsp::start_lsp;
use std::sync::OnceLock;

pub use tower_lsp::start_server;

pub mod config;
pub mod documents;
Expand All @@ -17,8 +19,11 @@ pub mod state;
pub mod to_proto;
pub mod tower_lsp;

#[cfg(test)]
pub mod tower_lsp_test_client;
/// Are we in a test LSP server?
///
/// `start_test_server()` sets this to `true`, which disables logging to the LSP client
/// during tests, making it easier to track sent/received requests.
pub static TESTING: OnceLock<bool> = OnceLock::new();

// These send LSP messages in a non-async and non-blocking way.
// The LOG level is not timestamped so we're not using it.
Expand Down
36 changes: 14 additions & 22 deletions crates/lsp/src/main_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use std::collections::HashMap;
use std::future;
use std::pin::Pin;
use std::sync::OnceLock;

use anyhow::anyhow;
use biome_lsp_converters::PositionEncoding;
Expand All @@ -31,16 +32,15 @@ use crate::tower_lsp::LspMessage;
use crate::tower_lsp::LspNotification;
use crate::tower_lsp::LspRequest;
use crate::tower_lsp::LspResponse;
use crate::TESTING;

pub(crate) type TokioUnboundedSender<T> = tokio::sync::mpsc::UnboundedSender<T>;
pub(crate) type TokioUnboundedReceiver<T> = tokio::sync::mpsc::UnboundedReceiver<T>;

// The global instance of the auxiliary event channel, used for sending log
// messages or spawning threads from free functions. Since this is an unbounded
// channel, sending a log message is not async nor blocking. Tokio senders are
// Send and Sync so this global variable can be safely shared across threads.
static mut AUXILIARY_EVENT_TX: std::cell::OnceCell<TokioUnboundedSender<AuxiliaryEvent>> =
std::cell::OnceCell::new();
// channel, sending a log message is not async nor blocking.
static AUXILIARY_EVENT_TX: OnceLock<TokioUnboundedSender<AuxiliaryEvent>> = OnceLock::new();

// This is the syntax for trait aliases until an official one is stabilised.
// This alias is for the future of a `JoinHandle<anyhow::Result<T>>`
Expand Down Expand Up @@ -378,16 +378,9 @@ impl AuxiliaryState {
// Set global instance of this channel. This is used for interacting
// with the auxiliary loop (logging messages or spawning a task) from
// free functions.
unsafe {
#[allow(static_mut_refs)]
if let Some(val) = AUXILIARY_EVENT_TX.get_mut() {
// Reset channel if already set. Happens e.g. on reconnection after a refresh.
*val = auxiliary_event_tx;
} else {
// Set channel for the first time
AUXILIARY_EVENT_TX.set(auxiliary_event_tx).unwrap();
}
}
AUXILIARY_EVENT_TX
.set(auxiliary_event_tx)
.expect("Auxiliary event channel can't be set more than once.");
Comment on lines -381 to +383
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had the very important realization that unlike in the current ark setup, we won't ever go through here twice in air. There is no "reconnect" lifecycle to manage, our whole process is just going to get dropped and reestablished, so we don't have to worry about this anymore.

This was being triggered in our tests though - even if we ran the tests sequentially we'd come through here multiple times as we "start up" a new LSP within the same process. I've fixed that by running the LSP tests as integration tests with 1 client per process, truly ensuring we never run through here again.

Comment on lines -381 to +383
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notably you only see the expect() error when running with -- --nocapture.

This is because if the auxiliary or main loop threads panic, then we don't propagate the panic up to the main process at all. Tokio will return a JoinError if we actually await the JoinSet of the aux and main loops, we just need to do so in start_server() while we also do server.serve(service).await;. I couldn't easily figure that out here so I'm saving that for another PR.

But note that with this PR cargo test -- --nocapture no longer shows any panics, but did reliably before.


// List of pending tasks for which we manage the lifecycle (mainly relay
// errors and panics)
Expand Down Expand Up @@ -453,12 +446,10 @@ impl AuxiliaryState {
}

fn auxiliary_tx() -> &'static TokioUnboundedSender<AuxiliaryEvent> {
// If we get here that means the LSP was initialised at least once. The
// channel might be closed if the LSP was dropped, but it should exist.
unsafe {
#[allow(static_mut_refs)]
AUXILIARY_EVENT_TX.get().unwrap()
}
// If we get here that means the LSP was initialised in `AuxiliaryState::new()`.
// The channel might be closed if the LSP was dropped, but it should exist
// (and in that case we expect the process to exit shortly afterwards anyways).
AUXILIARY_EVENT_TX.get().unwrap()
}

fn send_auxiliary(event: AuxiliaryEvent) {
Expand All @@ -471,8 +462,9 @@ fn send_auxiliary(event: AuxiliaryEvent) {
/// Send a message to the LSP client. This is non-blocking and treated on a
/// latency-sensitive task.
pub(crate) fn log(level: lsp_types::MessageType, message: String) {
// We're not connected to an LSP client when running unit tests
if cfg!(test) {
// We don't want to send logs to the client when running integration tests,
// as they interfere with our ability to track sent/received requests.
if *TESTING.get().unwrap_or(&false) {
return;
}

Expand Down
88 changes: 28 additions & 60 deletions crates/lsp/src/tower_lsp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::handlers_ext::ViewFileParams;
use crate::main_loop::Event;
use crate::main_loop::GlobalState;
use crate::main_loop::TokioUnboundedSender;
use crate::TESTING;

// Based on https://stackoverflow.com/a/69324393/1725177
macro_rules! cast_response {
Expand Down Expand Up @@ -174,13 +175,39 @@ impl LanguageServer for Backend {
}
}

pub async fn start_lsp<I, O>(read: I, write: O)
/// Entry point for the LSP server
///
/// Should be called exactly once per process
pub async fn start_server<I, O>(read: I, write: O)
where
I: AsyncRead + Unpin,
O: AsyncWrite,
{
start_server_impl(read, write, false).await
}

/// Entry point for the test LSP server
///
/// Should be called exactly once per process
pub async fn start_test_server<I, O>(read: I, write: O)
where
I: AsyncRead + Unpin,
O: AsyncWrite,
{
start_server_impl(read, write, true).await
}

async fn start_server_impl<I, O>(read: I, write: O, testing: bool)
where
I: AsyncRead + Unpin,
O: AsyncWrite,
{
log::trace!("Starting LSP");

TESTING
.set(testing)
.expect("`TESTING` can only be set once.");

let (service, socket) = new_lsp();
let server = tower_lsp::Server::new(read, write, socket);
server.serve(service).await;
Expand Down Expand Up @@ -214,62 +241,3 @@ fn new_jsonrpc_error(message: String) -> jsonrpc::Error {
data: None,
}
}

#[cfg(test)]
pub(crate) async fn start_test_client() -> lsp_test::lsp_client::TestClient {
lsp_test::lsp_client::TestClient::new(|server_rx, client_tx| async {
start_lsp(server_rx, client_tx).await
})
}

#[cfg(test)]
pub(crate) async fn init_test_client() -> lsp_test::lsp_client::TestClient {
let mut client = start_test_client().await;

client.initialize().await;
client.recv_response().await;

client
}

#[cfg(test)]
mod tests {
use super::*;
use assert_matches::assert_matches;
use tower_lsp::lsp_types;

#[tests_macros::lsp_test]
async fn test_init() {
let mut client = start_test_client().await;

client.initialize().await;

let value = client.recv_response().await;
let value: lsp_types::InitializeResult =
serde_json::from_value(value.result().unwrap().clone()).unwrap();

assert_matches!(
value,
lsp_types::InitializeResult {
capabilities,
server_info
} => {
assert_matches!(capabilities, ServerCapabilities {
position_encoding,
text_document_sync,
..
} => {
assert_eq!(position_encoding, None);
assert_eq!(text_document_sync, Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::INCREMENTAL)));
});

assert_matches!(server_info, Some(ServerInfo { name, version }) => {
assert!(name.contains("Air Language Server"));
assert!(version.is_some());
});
}
);

client
}
}
5 changes: 5 additions & 0 deletions crates/lsp/tests/fixtures/mod.rs
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the "old" style mod.rs here in fixtures/ is the recommended way to have integration test helpers
https://doc.rust-lang.org/book/ch11-03-test-organization.html#submodules-in-integration-tests

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// For some reason this setup gives false alarms about dead code
#[allow(dead_code)]
pub mod test_client;
#[allow(dead_code)]
pub mod tower_lsp_test_client;
15 changes: 15 additions & 0 deletions crates/lsp/tests/fixtures/test_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use lsp::tower_lsp::start_test_server;
use lsp_test::lsp_client::TestClient;

pub async fn start_test_client() -> lsp_test::lsp_client::TestClient {
TestClient::new(|server_rx, client_tx| async { start_test_server(server_rx, client_tx).await })
}

pub async fn init_test_client() -> TestClient {
let mut client = start_test_client().await;

client.initialize().await;
client.recv_response().await;

client
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use lsp_test::lsp_client::TestClient;
use tower_lsp::lsp_types;

use crate::{documents::Document, from_proto};
use lsp::documents::Document;
use lsp::from_proto;

pub(crate) trait TestClientExt {
async fn open_document(&mut self, doc: &Document) -> lsp_types::TextDocumentItem;
Expand Down
27 changes: 27 additions & 0 deletions crates/lsp/tests/format_diff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
mod fixtures;

use fixtures::test_client::init_test_client;
use fixtures::tower_lsp_test_client::TestClientExt;
use lsp::documents::Document;

// https://github.com/posit-dev/air/issues/61
#[tests_macros::lsp_test]
async fn test_format_minimal_diff() {
let mut client = init_test_client().await;

#[rustfmt::skip]
let doc = Document::doodle(
"1
2+2
3
",
);

let edits = client.format_document_edits(&doc).await.unwrap();
assert!(edits.len() == 1);

let edit = &edits[0];
assert_eq!(edit.new_text, " + ");

client
}
24 changes: 24 additions & 0 deletions crates/lsp/tests/format_document.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
mod fixtures;

use fixtures::test_client::init_test_client;
use fixtures::tower_lsp_test_client::TestClientExt;
use lsp::documents::Document;

#[tests_macros::lsp_test]
async fn test_format() {
let mut client = init_test_client().await;

#[rustfmt::skip]
let doc = Document::doodle(
"
1
2+2
3 + 3 +
3",
);

let formatted = client.format_document(&doc).await;
insta::assert_snapshot!(formatted);

client
}
Loading
Loading