From 061090de7e95e8e7a97f3277bd1a72899ebd1570 Mon Sep 17 00:00:00 2001 From: Kitson Kelly Date: Wed, 30 Mar 2022 09:59:27 +1100 Subject: [PATCH] feat(lsp): add experimental testing API (#13798) Ref: denoland/vscode_deno#629 --- cli/bench/lsp.rs | 8 +- cli/bench/lsp_bench_standalone.rs | 2 +- cli/flags.rs | 6 +- cli/lsp/capabilities.rs | 1 + cli/lsp/client.rs | 40 + cli/lsp/config.rs | 39 +- cli/lsp/language_server.rs | 61 +- cli/lsp/mod.rs | 1 + cli/lsp/repl.rs | 5 + cli/lsp/testing/collectors.rs | 619 ++++++++++++ cli/lsp/testing/definitions.rs | 180 ++++ cli/lsp/testing/execution.rs | 947 ++++++++++++++++++ cli/lsp/testing/lsp_custom.rs | 186 ++++ cli/lsp/testing/mod.rs | 11 + cli/lsp/testing/server.rs | 219 ++++ cli/tests/integration/lsp_tests.rs | 261 ++++- cli/tests/testdata/lsp/initialize_params.json | 11 +- cli/tools/test.rs | 8 +- runtime/js/40_testing.js | 42 +- test_util/src/lsp.rs | 13 +- 20 files changed, 2608 insertions(+), 52 deletions(-) create mode 100644 cli/lsp/testing/collectors.rs create mode 100644 cli/lsp/testing/definitions.rs create mode 100644 cli/lsp/testing/execution.rs create mode 100644 cli/lsp/testing/lsp_custom.rs create mode 100644 cli/lsp/testing/mod.rs create mode 100644 cli/lsp/testing/server.rs diff --git a/cli/bench/lsp.rs b/cli/bench/lsp.rs index a7f712d0bc..fdddb9734b 100644 --- a/cli/bench/lsp.rs +++ b/cli/bench/lsp.rs @@ -44,7 +44,7 @@ struct FixtureMessage { /// the end of the document and does a level of hovering and gets quick fix /// code actions. fn bench_big_file_edits(deno_exe: &Path) -> Result { - let mut client = LspClient::new(deno_exe)?; + let mut client = LspClient::new(deno_exe, false)?; let params: Value = serde_json::from_slice(FIXTURE_INIT_JSON)?; let (_, response_error): (Option, Option) = @@ -125,7 +125,7 @@ fn bench_big_file_edits(deno_exe: &Path) -> Result { } fn bench_code_lens(deno_exe: &Path) -> Result { - let mut client = LspClient::new(deno_exe)?; + let mut client = LspClient::new(deno_exe, false)?; let params: Value = serde_json::from_slice(FIXTURE_INIT_JSON)?; let (_, maybe_err) = @@ -189,7 +189,7 @@ fn bench_code_lens(deno_exe: &Path) -> Result { } fn bench_find_replace(deno_exe: &Path) -> Result { - let mut client = LspClient::new(deno_exe)?; + let mut client = LspClient::new(deno_exe, false)?; let params: Value = serde_json::from_slice(FIXTURE_INIT_JSON)?; let (_, maybe_err) = @@ -285,7 +285,7 @@ fn bench_find_replace(deno_exe: &Path) -> Result { /// A test that starts up the LSP, opens a single line document, and exits. fn bench_startup_shutdown(deno_exe: &Path) -> Result { - let mut client = LspClient::new(deno_exe)?; + let mut client = LspClient::new(deno_exe, false)?; let params: Value = serde_json::from_slice(FIXTURE_INIT_JSON)?; let (_, response_error) = diff --git a/cli/bench/lsp_bench_standalone.rs b/cli/bench/lsp_bench_standalone.rs index 0caa8620d4..b8682f7cdc 100644 --- a/cli/bench/lsp_bench_standalone.rs +++ b/cli/bench/lsp_bench_standalone.rs @@ -12,7 +12,7 @@ use test_util::lsp::LspClient; // https://github.com/quick-lint/quick-lint-js/blob/35207e6616267c6c81be63f47ce97ec2452d60df/benchmark/benchmark-lsp/lsp-benchmarks.cpp#L223-L268 fn incremental_change_wait(bench: &mut Bencher) { let deno_exe = test_util::deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); static FIXTURE_INIT_JSON: &[u8] = include_bytes!("testdata/initialize_params.json"); diff --git a/cli/flags.rs b/cli/flags.rs index bf3ea703d5..1dda1ef610 100644 --- a/cli/flags.rs +++ b/cli/flags.rs @@ -472,7 +472,11 @@ To evaluate code in the shell: "; /// Main entry point for parsing deno's command line flags. -pub fn flags_from_vec(args: Vec) -> clap::Result { +pub fn flags_from_vec(args: I) -> clap::Result +where + I: IntoIterator, + T: Into + Clone, +{ let version = crate::version::deno(); let app = clap_root(&version); let matches = app.clone().try_get_matches_from(args)?; diff --git a/cli/lsp/capabilities.rs b/cli/lsp/capabilities.rs index ed371bab16..9f545d8930 100644 --- a/cli/lsp/capabilities.rs +++ b/cli/lsp/capabilities.rs @@ -138,6 +138,7 @@ pub fn server_capabilities( moniker_provider: None, experimental: Some(json!({ "denoConfigTasks": true, + "testingApi":true, })), } } diff --git a/cli/lsp/client.rs b/cli/lsp/client.rs index 92a5dfffb7..b5728961f2 100644 --- a/cli/lsp/client.rs +++ b/cli/lsp/client.rs @@ -16,6 +16,14 @@ use crate::lsp::repl::get_repl_workspace_settings; use super::config::SpecifierSettings; use super::config::SETTINGS_SECTION; use super::lsp_custom; +use super::testing::lsp_custom as testing_lsp_custom; + +#[derive(Debug)] +pub enum TestingNotification { + Module(testing_lsp_custom::TestModuleNotificationParams), + DeleteModule(testing_lsp_custom::TestModuleDeleteNotificationParams), + Progress(testing_lsp_custom::TestRunProgressParams), +} #[derive(Clone)] pub struct Client(Arc); @@ -51,6 +59,10 @@ impl Client { self.0.send_registry_state_notification(params).await; } + pub fn send_test_notification(&self, params: TestingNotification) { + self.0.send_test_notification(params); + } + pub async fn specifier_configurations( &self, specifiers: Vec, @@ -118,6 +130,7 @@ trait ClientTrait: Send + Sync { &self, params: lsp_custom::RegistryStateNotificationParams, ) -> AsyncReturn<()>; + fn send_test_notification(&self, params: TestingNotification); fn specifier_configurations( &self, uris: Vec, @@ -164,6 +177,31 @@ impl ClientTrait for LspowerClient { }) } + fn send_test_notification(&self, notification: TestingNotification) { + let client = self.0.clone(); + tokio::task::spawn(async move { + match notification { + TestingNotification::Module(params) => { + client + .send_custom_notification::( + params, + ) + .await + } + TestingNotification::DeleteModule(params) => client + .send_custom_notification::( + params, + ) + .await, + TestingNotification::Progress(params) => client + .send_custom_notification::( + params, + ) + .await, + } + }); + } + fn specifier_configurations( &self, uris: Vec, @@ -260,6 +298,8 @@ impl ClientTrait for ReplClient { Box::pin(future::ready(())) } + fn send_test_notification(&self, _params: TestingNotification) {} + fn specifier_configurations( &self, uris: Vec, diff --git a/cli/lsp/config.rs b/cli/lsp/config.rs index 7b42949439..6cd401b0c5 100644 --- a/cli/lsp/config.rs +++ b/cli/lsp/config.rs @@ -21,6 +21,10 @@ pub struct ClientCapabilities { pub code_action_disabled_support: bool, pub line_folding_only: bool, pub status_notification: bool, + /// The client provides the `experimental.testingApi` capability, which is + /// built around VSCode's testing API. It indicates that the server should + /// send notifications about tests discovered in modules. + pub testing_api: bool, pub workspace_configuration: bool, pub workspace_did_change_watched_files: bool, } @@ -139,6 +143,28 @@ pub struct SpecifierSettings { pub code_lens: CodeLensSpecifierSettings, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TestingSettings { + /// A vector of arguments which should be used when running the tests for + /// a workspace. + #[serde(default)] + pub args: Vec, + /// Enable or disable the testing API if the client is capable of supporting + /// the testing API. + #[serde(default = "is_true")] + pub enable: bool, +} + +impl Default for TestingSettings { + fn default() -> Self { + Self { + args: vec!["--allow-all".to_string(), "--no-check".to_string()], + enable: true, + } + } +} + /// Deno language server specific settings that are applied to a workspace. #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -184,6 +210,10 @@ pub struct WorkspaceSettings { #[serde(default)] pub suggest: CompletionSettings, + /// Testing settings for the workspace. + #[serde(default)] + pub testing: TestingSettings, + /// An option which sets the cert file to use when attempting to fetch remote /// resources. This overrides `DENO_CERT` if present. pub tls_certificate: Option, @@ -333,7 +363,10 @@ impl Config { self.client_capabilities.status_notification = experimental .get("statusNotification") .and_then(|it| it.as_bool()) - == Some(true) + == Some(true); + self.client_capabilities.testing_api = + experimental.get("testingApi").and_then(|it| it.as_bool()) + == Some(true); } if let Some(workspace) = &capabilities.workspace { @@ -530,6 +563,10 @@ mod tests { hosts: HashMap::new(), } }, + testing: TestingSettings { + args: vec!["--allow-all".to_string(), "--no-check".to_string()], + enable: true + }, tls_certificate: None, unsafely_ignore_certificate_errors: None, unstable: false, diff --git a/cli/lsp/language_server.rs b/cli/lsp/language_server.rs index e0d99aa696..4555e3b854 100644 --- a/cli/lsp/language_server.rs +++ b/cli/lsp/language_server.rs @@ -47,6 +47,7 @@ use super::performance::Performance; use super::refactor; use super::registries::ModuleRegistry; use super::registries::ModuleRegistryOptions; +use super::testing; use super::text; use super::tsc; use super::tsc::Assets; @@ -107,14 +108,16 @@ pub struct Inner { /// An optional configuration file which has been specified in the client /// options. maybe_config_file: Option, - /// An optional configuration for linter which has been taken from specified config file. - pub maybe_lint_config: Option, /// An optional configuration for formatter which has been taken from specified config file. maybe_fmt_config: Option, /// An optional import map which is used to resolve modules. pub maybe_import_map: Option>, /// The URL for the import map which is used to determine relative imports. maybe_import_map_uri: Option, + /// An optional configuration for linter which has been taken from specified config file. + pub maybe_lint_config: Option, + /// A lazily create "server" for handling test run requests. + maybe_testing_server: Option, /// A collection of measurements which instrument that performance of the LSP. performance: Arc, /// A memoized version of fixable diagnostic codes retrieved from TypeScript. @@ -163,12 +166,13 @@ impl Inner { diagnostics_server, documents, maybe_cache_path: None, - maybe_lint_config: None, - maybe_fmt_config: None, maybe_cache_server: None, maybe_config_file: None, maybe_import_map: None, maybe_import_map_uri: None, + maybe_lint_config: None, + maybe_fmt_config: None, + maybe_testing_server: None, module_registries, module_registries_location, performance, @@ -781,6 +785,15 @@ impl Inner { } self.config.update_enabled_paths(self.client.clone()).await; + if self.config.client_capabilities.testing_api { + let test_server = testing::TestServer::new( + self.client.clone(), + self.performance.clone(), + self.config.root_uri.clone(), + ); + self.maybe_testing_server = Some(test_server); + } + lsp_log!("Server ready."); } @@ -835,6 +848,7 @@ impl Inner { .diagnostics_server .invalidate(&self.documents.dependents(&specifier)); self.send_diagnostics_update(); + self.send_testing_update(); } } Err(err) => error!("{}", err), @@ -860,6 +874,7 @@ impl Inner { specifiers.push(specifier.clone()); self.diagnostics_server.invalidate(&specifiers); self.send_diagnostics_update(); + self.send_testing_update(); } self.performance.measure(mark); } @@ -909,6 +924,7 @@ impl Inner { ); self.send_diagnostics_update(); + self.send_testing_update(); } async fn did_change_watched_files( @@ -954,6 +970,7 @@ impl Inner { ); self.diagnostics_server.invalidate_all(); self.send_diagnostics_update(); + self.send_testing_update(); } self.performance.measure(mark); } @@ -2143,6 +2160,29 @@ impl Inner { self.reload_import_registries().await } lsp_custom::TASK_REQUEST => self.get_tasks(), + testing::TEST_RUN_REQUEST => { + if let Some(testing_server) = &self.maybe_testing_server { + match params.map(serde_json::from_value) { + Some(Ok(params)) => testing_server + .run_request(params, self.config.get_workspace_settings()), + Some(Err(err)) => Err(LspError::invalid_params(err.to_string())), + None => Err(LspError::invalid_params("Missing parameters")), + } + } else { + Err(LspError::invalid_request()) + } + } + testing::TEST_RUN_CANCEL_REQUEST => { + if let Some(testing_server) = &self.maybe_testing_server { + match params.map(serde_json::from_value) { + Some(Ok(params)) => testing_server.run_cancel_request(params), + Some(Err(err)) => Err(LspError::invalid_params(err.to_string())), + None => Err(LspError::invalid_params("Missing parameters")), + } + } else { + Err(LspError::invalid_request()) + } + } lsp_custom::VIRTUAL_TEXT_DOCUMENT => { match params.map(serde_json::from_value) { Some(Ok(params)) => Ok(Some( @@ -2389,6 +2429,16 @@ impl Inner { error!("Cannot update diagnostics: {}", err); } } + + /// Send a message to the testing server to look for any changes in tests and + /// update the client. + fn send_testing_update(&self) { + if let Some(testing_server) = &self.maybe_testing_server { + if let Err(err) = testing_server.update(self.snapshot()) { + error!("Cannot update testing server: {}", err); + } + } + } } #[lspower::async_trait] @@ -2432,6 +2482,7 @@ impl lspower::LanguageServer for LanguageServer { // don't send diagnostics yet if we don't have the specifier settings if has_specifier_settings { inner.send_diagnostics_update(); + inner.send_testing_update(); } } (client, uri, specifier, has_specifier_settings) @@ -2464,6 +2515,7 @@ impl lspower::LanguageServer for LanguageServer { .unwrap_or(false) { inner.send_diagnostics_update(); + inner.send_testing_update(); } }); } @@ -2823,6 +2875,7 @@ impl Inner { // invalidate some diagnostics self.diagnostics_server.invalidate(&[referrer]); self.send_diagnostics_update(); + self.send_testing_update(); self.performance.measure(mark); Ok(Some(json!(true))) diff --git a/cli/lsp/mod.rs b/cli/lsp/mod.rs index afaf475483..1cfd3041cf 100644 --- a/cli/lsp/mod.rs +++ b/cli/lsp/mod.rs @@ -26,6 +26,7 @@ mod refactor; mod registries; mod repl; mod semantic_tokens; +mod testing; mod text; mod tsc; mod urls; diff --git a/cli/lsp/repl.rs b/cli/lsp/repl.rs index 1026c7bdae..44634580de 100644 --- a/cli/lsp/repl.rs +++ b/cli/lsp/repl.rs @@ -36,6 +36,7 @@ use lspower::LanguageServer; use super::client::Client; use super::config::CompletionSettings; use super::config::ImportCompletionSettings; +use super::config::TestingSettings; use super::config::WorkspaceSettings; #[derive(Debug)] @@ -294,5 +295,9 @@ pub fn get_repl_workspace_settings() -> WorkspaceSettings { hosts: HashMap::from([("https://deno.land".to_string(), true)]), }, }, + testing: TestingSettings { + args: vec![], + enable: false, + }, } } diff --git a/cli/lsp/testing/collectors.rs b/cli/lsp/testing/collectors.rs new file mode 100644 index 0000000000..572c037267 --- /dev/null +++ b/cli/lsp/testing/collectors.rs @@ -0,0 +1,619 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use super::definitions::TestDefinition; + +use deno_ast::swc::ast; +use deno_ast::swc::common::Span; +use deno_ast::swc::visit::Visit; +use deno_ast::swc::visit::VisitWith; +use deno_core::ModuleSpecifier; +use std::collections::HashSet; + +/// Parse an arrow expression for any test steps and return them. +fn arrow_to_steps( + parent: &str, + level: usize, + arrow_expr: &ast::ArrowExpr, +) -> Option> { + if let Some((maybe_test_context, maybe_step_var)) = + parse_test_context_param(arrow_expr.params.get(0)) + { + let mut collector = TestStepCollector::new( + parent.to_string(), + level, + maybe_test_context, + maybe_step_var, + ); + arrow_expr.body.visit_with(&mut collector); + let steps = collector.take(); + if !steps.is_empty() { + Some(steps) + } else { + None + } + } else { + None + } +} + +/// Parse a function for any test steps and return them. +fn fn_to_steps( + parent: &str, + level: usize, + function: &ast::Function, +) -> Option> { + if let Some((maybe_test_context, maybe_step_var)) = + parse_test_context_param(function.params.get(0).map(|p| &p.pat)) + { + let mut collector = TestStepCollector::new( + parent.to_string(), + level, + maybe_test_context, + maybe_step_var, + ); + function.body.visit_with(&mut collector); + let steps = collector.take(); + if !steps.is_empty() { + Some(steps) + } else { + None + } + } else { + None + } +} + +/// Parse a param of a test function for the test context binding, or any +/// destructuring of a `steps` method from the test context. +fn parse_test_context_param( + param: Option<&ast::Pat>, +) -> Option<(Option, Option)> { + let mut maybe_test_context = None; + let mut maybe_step_var = None; + match param { + // handles `(testContext)` + Some(ast::Pat::Ident(binding_ident)) => { + maybe_test_context = Some(binding_ident.id.sym.to_string()); + } + Some(ast::Pat::Object(object_pattern)) => { + for prop in &object_pattern.props { + match prop { + ast::ObjectPatProp::KeyValue(key_value_pat_prop) => { + match &key_value_pat_prop.key { + // handles `({ step: s })` + ast::PropName::Ident(ident) => { + if ident.sym.eq("step") { + if let ast::Pat::Ident(ident) = + key_value_pat_prop.value.as_ref() + { + maybe_step_var = Some(ident.id.sym.to_string()); + } + break; + } + } + // handles `({ "step": s })` + ast::PropName::Str(string) => { + if string.value.eq("step") { + if let ast::Pat::Ident(ident) = + key_value_pat_prop.value.as_ref() + { + maybe_step_var = Some(ident.id.sym.to_string()); + } + break; + } + } + _ => (), + } + } + // handles `({ step = something })` + ast::ObjectPatProp::Assign(assign_pat_prop) + if assign_pat_prop.key.sym.eq("step") => + { + maybe_step_var = Some("step".to_string()); + break; + } + // handles `({ ...ctx })` + ast::ObjectPatProp::Rest(rest_pat) => { + if let ast::Pat::Ident(ident) = rest_pat.arg.as_ref() { + maybe_test_context = Some(ident.id.sym.to_string()); + } + break; + } + _ => (), + } + } + } + _ => return None, + } + if maybe_test_context.is_none() && maybe_step_var.is_none() { + None + } else { + Some((maybe_test_context, maybe_step_var)) + } +} + +/// Check a call expression of a test or test step to determine the name of the +/// test or test step as well as any sub steps. +fn check_call_expr( + parent: &str, + node: &ast::CallExpr, + level: usize, +) -> Option<(String, Option>)> { + if let Some(expr) = node.args.get(0).map(|es| es.expr.as_ref()) { + match expr { + ast::Expr::Object(obj_lit) => { + let mut maybe_name = None; + let mut steps = None; + for prop in &obj_lit.props { + if let ast::PropOrSpread::Prop(prop) = prop { + match prop.as_ref() { + ast::Prop::KeyValue(key_value_prop) => { + if let ast::PropName::Ident(ast::Ident { sym, .. }) = + &key_value_prop.key + { + match sym.to_string().as_str() { + "name" => match key_value_prop.value.as_ref() { + // matches string literals (e.g. "test name" or + // 'test name') + ast::Expr::Lit(ast::Lit::Str(lit_str)) => { + maybe_name = Some(lit_str.value.to_string()); + } + // matches template literals with only a single quasis + // (e.g. `test name`) + ast::Expr::Tpl(tpl) => { + if tpl.quasis.len() == 1 { + if let Some(tpl_element) = tpl.quasis.get(0) { + maybe_name = + Some(tpl_element.raw.value.to_string()); + } + } + } + _ => (), + }, + "fn" => match key_value_prop.value.as_ref() { + ast::Expr::Arrow(arrow_expr) => { + steps = arrow_to_steps(parent, level, arrow_expr); + } + ast::Expr::Fn(fn_expr) => { + steps = fn_to_steps(parent, level, &fn_expr.function); + } + _ => (), + }, + _ => (), + } + } + } + ast::Prop::Method(method_prop) => { + steps = fn_to_steps(parent, level, &method_prop.function); + } + _ => (), + } + } + } + maybe_name.map(|name| (name, steps)) + } + ast::Expr::Fn(fn_expr) => { + if let Some(ast::Ident { sym, .. }) = fn_expr.ident.as_ref() { + let name = sym.to_string(); + let steps = fn_to_steps(parent, level, &fn_expr.function); + Some((name, steps)) + } else { + None + } + } + ast::Expr::Lit(ast::Lit::Str(lit_str)) => { + let name = lit_str.value.to_string(); + let mut steps = None; + match node.args.get(1).map(|es| es.expr.as_ref()) { + Some(ast::Expr::Fn(fn_expr)) => { + steps = fn_to_steps(parent, level, &fn_expr.function); + } + Some(ast::Expr::Arrow(arrow_expr)) => { + steps = arrow_to_steps(parent, level, arrow_expr); + } + _ => (), + } + Some((name, steps)) + } + _ => None, + } + } else { + None + } +} + +/// A structure which can be used to walk a branch of AST determining if the +/// branch contains any testing steps. +struct TestStepCollector { + steps: Vec, + level: usize, + parent: String, + maybe_test_context: Option, + vars: HashSet, +} + +impl TestStepCollector { + fn new( + parent: String, + level: usize, + maybe_test_context: Option, + maybe_step_var: Option, + ) -> Self { + let mut vars = HashSet::new(); + if let Some(var) = maybe_step_var { + vars.insert(var); + } + Self { + steps: Vec::default(), + level, + parent, + maybe_test_context, + vars, + } + } + + fn add_step>( + &mut self, + name: N, + span: &Span, + steps: Option>, + ) { + let step = TestDefinition::new_step( + name.as_ref().to_string(), + *span, + self.parent.clone(), + self.level, + steps, + ); + self.steps.push(step); + } + + fn check_call_expr(&mut self, node: &ast::CallExpr, span: &Span) { + if let Some((name, steps)) = + check_call_expr(&self.parent, node, self.level + 1) + { + self.add_step(name, span, steps); + } + } + + /// Move out the test definitions + pub fn take(self) -> Vec { + self.steps + } +} + +impl Visit for TestStepCollector { + fn visit_call_expr(&mut self, node: &ast::CallExpr) { + if let ast::Callee::Expr(callee_expr) = &node.callee { + match callee_expr.as_ref() { + // Identify calls to identified variables + ast::Expr::Ident(ident) => { + if self.vars.contains(&ident.sym.to_string()) { + self.check_call_expr(node, &ident.span); + } + } + // Identify calls to `test.step()` + ast::Expr::Member(member_expr) => { + if let Some(test_context) = &self.maybe_test_context { + if let ast::MemberProp::Ident(ns_prop_ident) = &member_expr.prop { + if ns_prop_ident.sym.eq("step") { + if let ast::Expr::Ident(ident) = member_expr.obj.as_ref() { + if ident.sym == *test_context { + self.check_call_expr(node, &ns_prop_ident.span); + } + } + } + } + } + } + _ => (), + } + } + } + + fn visit_var_decl(&mut self, node: &ast::VarDecl) { + if let Some(test_context) = &self.maybe_test_context { + for decl in &node.decls { + if let Some(init) = &decl.init { + match init.as_ref() { + // Identify destructured assignments of `step` from test context + ast::Expr::Ident(ident) => { + if ident.sym == *test_context { + if let ast::Pat::Object(object_pat) = &decl.name { + for prop in &object_pat.props { + match prop { + ast::ObjectPatProp::Assign(prop) => { + if prop.key.sym.eq("step") { + self.vars.insert(prop.key.sym.to_string()); + } + } + ast::ObjectPatProp::KeyValue(prop) => { + if let ast::PropName::Ident(key_ident) = &prop.key { + if key_ident.sym.eq("step") { + if let ast::Pat::Ident(value_ident) = + &prop.value.as_ref() + { + self.vars.insert(value_ident.id.sym.to_string()); + } + } + } + } + _ => (), + } + } + } + } + } + // Identify variable assignments where the init is test context + // `.step` + ast::Expr::Member(member_expr) => { + if let ast::Expr::Ident(obj_ident) = member_expr.obj.as_ref() { + if obj_ident.sym == *test_context { + if let ast::MemberProp::Ident(prop_ident) = &member_expr.prop + { + if prop_ident.sym.eq("step") { + if let ast::Pat::Ident(binding_ident) = &decl.name { + self.vars.insert(binding_ident.id.sym.to_string()); + } + } + } + } + } + } + _ => (), + } + } + } + } + } +} + +/// Walk an AST and determine if it contains any `Deno.test` tests. +pub struct TestCollector { + definitions: Vec, + specifier: ModuleSpecifier, + vars: HashSet, +} + +impl TestCollector { + pub fn new(specifier: ModuleSpecifier) -> Self { + Self { + definitions: Vec::new(), + specifier, + vars: HashSet::new(), + } + } + + fn add_definition>( + &mut self, + name: N, + span: &Span, + steps: Option>, + ) { + let definition = TestDefinition::new( + &self.specifier, + name.as_ref().to_string(), + *span, + steps, + ); + self.definitions.push(definition); + } + + fn check_call_expr(&mut self, node: &ast::CallExpr, span: &Span) { + if let Some((name, steps)) = + check_call_expr(self.specifier.as_str(), node, 1) + { + self.add_definition(name, span, steps); + } + } + + /// Move out the test definitions + pub fn take(self) -> Vec { + self.definitions + } +} + +impl Visit for TestCollector { + fn visit_call_expr(&mut self, node: &ast::CallExpr) { + if let ast::Callee::Expr(callee_expr) = &node.callee { + match callee_expr.as_ref() { + ast::Expr::Ident(ident) => { + if self.vars.contains(&ident.sym.to_string()) { + self.check_call_expr(node, &ident.span); + } + } + ast::Expr::Member(member_expr) => { + if let ast::MemberProp::Ident(ns_prop_ident) = &member_expr.prop { + if ns_prop_ident.sym.to_string() == "test" { + if let ast::Expr::Ident(ident) = member_expr.obj.as_ref() { + if ident.sym.to_string() == "Deno" { + self.check_call_expr(node, &ns_prop_ident.span); + } + } + } + } + } + _ => (), + } + } + } + + fn visit_var_decl(&mut self, node: &ast::VarDecl) { + for decl in &node.decls { + if let Some(init) = &decl.init { + match init.as_ref() { + // Identify destructured assignments of `test` from `Deno` + ast::Expr::Ident(ident) => { + if ident.sym.to_string() == "Deno" { + if let ast::Pat::Object(object_pat) = &decl.name { + for prop in &object_pat.props { + match prop { + ast::ObjectPatProp::Assign(prop) => { + let name = prop.key.sym.to_string(); + if name == "test" { + self.vars.insert(name); + } + } + ast::ObjectPatProp::KeyValue(prop) => { + if let ast::PropName::Ident(key_ident) = &prop.key { + if key_ident.sym.to_string() == "test" { + if let ast::Pat::Ident(value_ident) = + &prop.value.as_ref() + { + self.vars.insert(value_ident.id.sym.to_string()); + } + } + } + } + _ => (), + } + } + } + } + } + // Identify variable assignments where the init is `Deno.test` + ast::Expr::Member(member_expr) => { + if let ast::Expr::Ident(obj_ident) = member_expr.obj.as_ref() { + if obj_ident.sym.to_string() == "Deno" { + if let ast::MemberProp::Ident(prop_ident) = &member_expr.prop { + if prop_ident.sym.to_string() == "test" { + if let ast::Pat::Ident(binding_ident) = &decl.name { + self.vars.insert(binding_ident.id.sym.to_string()); + } + } + } + } + } + } + _ => (), + } + } + } + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use deno_ast::swc::common::BytePos; + use deno_ast::swc::common::SyntaxContext; + use deno_core::resolve_url; + use std::sync::Arc; + + pub fn new_span(lo: u32, hi: u32, ctxt: u32) -> Span { + Span { + lo: BytePos(lo), + hi: BytePos(hi), + ctxt: SyntaxContext::from_u32(ctxt), + } + } + + #[test] + fn test_test_collector() { + let specifier = resolve_url("file:///a/example.ts").unwrap(); + let source = Arc::new( + r#" + Deno.test({ + name: "test a", + async fn(t) { + await t.step("a step", ({ step }) => { + await step({ + name: "sub step", + fn() {} + }) + }); + } + }); + + Deno.test(async function useFnName({ step: s }) { + await s("step c", () => {}); + }); + + Deno.test("test b", () => {}); + + const { test } = Deno; + test("test c", () => {}); + + const t = Deno.test; + t("test d", () => {}); + "# + .to_string(), + ); + + let parsed_module = deno_ast::parse_module(deno_ast::ParseParams { + specifier: specifier.to_string(), + source: deno_ast::SourceTextInfo::new(source), + media_type: deno_ast::MediaType::TypeScript, + capture_tokens: true, + scope_analysis: true, + maybe_syntax: None, + }) + .unwrap(); + let mut collector = TestCollector::new(specifier); + parsed_module.module().visit_with(&mut collector); + assert_eq!( + collector.take(), + vec![ + TestDefinition { + id: "cf31850c831233526df427cdfd25b6b84b2af0d6ce5f8ee1d22c465234b46348".to_string(), + level: 0, + name: "test a".to_string(), + span: new_span(12, 16, 0), + steps: Some(vec![ + TestDefinition { + id: "4c7333a1e47721631224408c467f32751fe34b876cab5ec1f6ac71980ff15ad3".to_string(), + level: 1, + name: "a step".to_string(), + span: new_span(83, 87, 0), + steps: Some(vec![ + TestDefinition { + id: "abf356f59139b77574089615f896a6f501c010985d95b8a93abeb0069ccb2201".to_string(), + level: 2, + name: "sub step".to_string(), + span: new_span(132, 136, 3), + steps: None, + } + ]) + } + ]), + }, + TestDefinition { + id: "86b4c821900e38fc89f24bceb0e45193608ab3f9d2a6019c7b6a5aceff5d7df2".to_string(), + level: 0, + name: "useFnName".to_string(), + span: new_span(254, 258, 0), + steps: Some(vec![ + TestDefinition { + id: "67a390d0084ae5fb88f3510c470a72a553581f1d0d5ba5fa89aee7a754f3953a".to_string(), + level: 1, + name: "step c".to_string(), + span: new_span(313, 314, 4), + steps: None, + } + ]) + }, + TestDefinition { + id: "580eda89d7f5e619774c20e13b7d07a8e77c39cba101d60565144d48faa837cb".to_string(), + level: 0, + name: "test b".to_string(), + span: new_span(358, 362, 0), + steps: None, + }, + TestDefinition { + id: "0b7c6bf3cd617018d33a1bf982a08fe088c5bb54fcd5eb9e802e7c137ec1af94".to_string(), + level: 0, + name: "test c".to_string(), + span: new_span(420, 424, 1), + steps: None, + }, + TestDefinition { + id: "69d9fe87f64f5b66cb8b631d4fd2064e8224b8715a049be54276c42189ff8f9f".to_string(), + level: 0, + name: "test d".to_string(), + span: new_span(480, 481, 1), + steps: None, + } + ] + ); + } +} diff --git a/cli/lsp/testing/definitions.rs b/cli/lsp/testing/definitions.rs new file mode 100644 index 0000000000..0fa6a8fd52 --- /dev/null +++ b/cli/lsp/testing/definitions.rs @@ -0,0 +1,180 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use super::lsp_custom; + +use crate::checksum; +use crate::lsp::client::TestingNotification; + +use deno_ast::swc::common::Span; +use deno_ast::SourceTextInfo; +use deno_core::ModuleSpecifier; +use lspower::lsp; +use std::collections::HashMap; + +fn span_to_range( + span: &Span, + source_text_info: &SourceTextInfo, +) -> Option { + let start = source_text_info.line_and_column_index(span.lo); + let end = source_text_info.line_and_column_index(span.hi); + Some(lsp::Range { + start: lsp::Position { + line: start.line_index as u32, + character: start.column_index as u32, + }, + end: lsp::Position { + line: end.line_index as u32, + character: end.column_index as u32, + }, + }) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TestDefinition { + pub id: String, + pub level: usize, + pub name: String, + pub span: Span, + pub steps: Option>, +} + +impl TestDefinition { + pub fn new( + specifier: &ModuleSpecifier, + name: String, + span: Span, + steps: Option>, + ) -> Self { + let id = checksum::gen(&[specifier.as_str().as_bytes(), name.as_bytes()]); + Self { + id, + level: 0, + name, + span, + steps, + } + } + + pub fn new_step( + name: String, + span: Span, + parent: String, + level: usize, + steps: Option>, + ) -> Self { + let id = checksum::gen(&[ + parent.as_bytes(), + &level.to_be_bytes(), + name.as_bytes(), + ]); + Self { + id, + level, + name, + span, + steps, + } + } + + fn as_test_data( + &self, + source_text_info: &SourceTextInfo, + ) -> lsp_custom::TestData { + lsp_custom::TestData { + id: self.id.clone(), + label: self.name.clone(), + steps: self.steps.as_ref().map(|steps| { + steps + .iter() + .map(|step| step.as_test_data(source_text_info)) + .collect() + }), + range: span_to_range(&self.span, source_text_info), + } + } + + fn find_step(&self, name: &str, level: usize) -> Option<&TestDefinition> { + if let Some(steps) = &self.steps { + for step in steps { + if step.name == name && step.level == level { + return Some(step); + } else if let Some(step) = step.find_step(name, level) { + return Some(step); + } + } + } + None + } +} + +#[derive(Debug, Clone)] +pub struct TestDefinitions { + /// definitions of tests and their steps which were statically discovered from + /// the source document. + pub discovered: Vec, + /// Tests and steps which the test runner notified us of, which were + /// dynamically added + pub injected: Vec, + /// The version of the document that the discovered tests relate to. + pub script_version: String, +} + +impl TestDefinitions { + /// Return the test definitions as a testing module notification. + pub fn as_notification( + &self, + specifier: &ModuleSpecifier, + maybe_root: Option<&ModuleSpecifier>, + source_text_info: &SourceTextInfo, + ) -> TestingNotification { + let label = if let Some(root) = maybe_root { + specifier.as_str().replace(root.as_str(), "") + } else { + specifier + .path_segments() + .and_then(|s| s.last().map(|s| s.to_string())) + .unwrap_or_else(|| "".to_string()) + }; + let mut tests_map: HashMap = self + .injected + .iter() + .map(|td| (td.id.clone(), td.clone())) + .collect(); + tests_map.extend(self.discovered.iter().map(|td| { + let test_data = td.as_test_data(source_text_info); + (test_data.id.clone(), test_data) + })); + TestingNotification::Module(lsp_custom::TestModuleNotificationParams { + text_document: lsp::TextDocumentIdentifier { + uri: specifier.clone(), + }, + kind: lsp_custom::TestModuleNotificationKind::Replace, + label, + tests: tests_map.into_values().collect(), + }) + } + + /// Return a test definition identified by the test ID. + pub fn get_by_id>(&self, id: S) -> Option<&TestDefinition> { + self + .discovered + .iter() + .find(|td| td.id.as_str() == id.as_ref()) + } + + /// Return a test definition by the test name. + pub fn get_by_name(&self, name: &str) -> Option<&TestDefinition> { + self.discovered.iter().find(|td| td.name.as_str() == name) + } + + pub fn get_step_by_name( + &self, + test_name: &str, + level: usize, + name: &str, + ) -> Option<&TestDefinition> { + self + .get_by_name(test_name) + .and_then(|td| td.find_step(name, level)) + } +} diff --git a/cli/lsp/testing/execution.rs b/cli/lsp/testing/execution.rs new file mode 100644 index 0000000000..03436ad6a0 --- /dev/null +++ b/cli/lsp/testing/execution.rs @@ -0,0 +1,947 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use super::definitions::TestDefinition; +use super::definitions::TestDefinitions; +use super::lsp_custom; + +use crate::checksum; +use crate::create_main_worker; +use crate::emit; +use crate::flags; +use crate::located_script_name; +use crate::lsp::client::Client; +use crate::lsp::client::TestingNotification; +use crate::lsp::config; +use crate::lsp::logging::lsp_log; +use crate::ops; +use crate::proc_state; +use crate::tools::test; + +use deno_core::anyhow::anyhow; +use deno_core::error::AnyError; +use deno_core::futures::future; +use deno_core::futures::stream; +use deno_core::futures::StreamExt; +use deno_core::parking_lot::Mutex; +use deno_core::serde_json::json; +use deno_core::serde_json::Value; +use deno_core::ModuleSpecifier; +use deno_runtime::permissions::Permissions; +use deno_runtime::tokio_util::run_basic; +use lspower::lsp; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +/// Logic to convert a test request into a set of test modules to be tested and +/// any filters to be applied to those tests +fn as_queue_and_filters( + params: &lsp_custom::TestRunRequestParams, + tests: &HashMap, +) -> ( + HashSet, + HashMap, +) { + let mut queue: HashSet = HashSet::new(); + let mut filters: HashMap = HashMap::new(); + + if let Some(include) = ¶ms.include { + for item in include { + if let Some(test_definitions) = tests.get(&item.text_document.uri) { + queue.insert(item.text_document.uri.clone()); + if let Some(id) = &item.id { + if let Some(test) = test_definitions.get_by_id(id) { + let filter = + filters.entry(item.text_document.uri.clone()).or_default(); + if let Some(include) = filter.maybe_include.as_mut() { + include.insert(test.id.clone(), test.clone()); + } else { + let mut include = HashMap::new(); + include.insert(test.id.clone(), test.clone()); + filter.maybe_include = Some(include); + } + } + } + } + } + } + + // if we didn't have any specific include filters, we assume that all modules + // will be tested + if queue.is_empty() { + queue.extend(tests.keys().cloned()); + } + + if let Some(exclude) = ¶ms.exclude { + for item in exclude { + if let Some(test_definitions) = tests.get(&item.text_document.uri) { + if let Some(id) = &item.id { + // there is currently no way to filter out a specific test, so we have + // to ignore the exclusion + if item.step_id.is_none() { + if let Some(test) = test_definitions.get_by_id(id) { + let filter = + filters.entry(item.text_document.uri.clone()).or_default(); + if let Some(exclude) = filter.maybe_exclude.as_mut() { + exclude.insert(test.id.clone(), test.clone()); + } else { + let mut exclude = HashMap::new(); + exclude.insert(test.id.clone(), test.clone()); + filter.maybe_exclude = Some(exclude); + } + } + } + } else { + // the entire test module is excluded + queue.remove(&item.text_document.uri); + } + } + } + } + + (queue, filters) +} + +fn as_test_messages>( + message: S, + is_markdown: bool, +) -> Vec { + let message = lsp::MarkupContent { + kind: if is_markdown { + lsp::MarkupKind::Markdown + } else { + lsp::MarkupKind::PlainText + }, + value: message.as_ref().to_string(), + }; + vec![lsp_custom::TestMessage { + message, + expected_output: None, + actual_output: None, + location: None, + }] +} + +#[derive(Debug, Clone, Default, PartialEq)] +struct TestFilter { + maybe_include: Option>, + maybe_exclude: Option>, +} + +impl TestFilter { + fn as_ids(&self, test_definitions: &TestDefinitions) -> Vec { + let ids: Vec = if let Some(include) = &self.maybe_include { + include.keys().cloned().collect() + } else { + test_definitions + .discovered + .iter() + .map(|td| td.id.clone()) + .collect() + }; + if let Some(exclude) = &self.maybe_exclude { + ids + .into_iter() + .filter(|id| !exclude.contains_key(id)) + .collect() + } else { + ids + } + } + + /// return the filter as a JSON value, suitable for sending as a filter to the + /// test runner. + fn as_test_options(&self) -> Value { + let maybe_include: Option> = self + .maybe_include + .as_ref() + .map(|inc| inc.iter().map(|(_, td)| td.name.clone()).collect()); + let maybe_exclude: Option> = self + .maybe_exclude + .as_ref() + .map(|ex| ex.iter().map(|(_, td)| td.name.clone()).collect()); + json!({ + "filter": { + "include": maybe_include, + "exclude": maybe_exclude, + } + }) + } +} + +async fn test_specifier( + ps: proc_state::ProcState, + permissions: Permissions, + specifier: ModuleSpecifier, + mode: test::TestMode, + channel: mpsc::UnboundedSender, + token: CancellationToken, + options: Option, +) -> Result<(), AnyError> { + if !token.is_cancelled() { + let mut worker = create_main_worker( + &ps, + specifier.clone(), + permissions, + vec![ops::testing::init(channel.clone())], + ); + + worker + .execute_script( + &located_script_name!(), + "Deno.core.enableOpCallTracing();", + ) + .unwrap(); + + if mode != test::TestMode::Documentation { + worker.execute_side_module(&specifier).await?; + } + + worker.dispatch_load_event(&located_script_name!())?; + + let options = options.unwrap_or_else(|| json!({})); + let test_result = worker.js_runtime.execute_script( + &located_script_name!(), + &format!(r#"Deno[Deno.internal].runTests({})"#, json!(options)), + )?; + + worker.js_runtime.resolve_value(test_result).await?; + + worker.dispatch_unload_event(&located_script_name!())?; + } + + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct TestRun { + id: u32, + kind: lsp_custom::TestRunKind, + filters: HashMap, + queue: HashSet, + tests: Arc>>, + token: CancellationToken, + workspace_settings: config::WorkspaceSettings, +} + +impl TestRun { + pub fn new( + params: &lsp_custom::TestRunRequestParams, + tests: Arc>>, + workspace_settings: config::WorkspaceSettings, + ) -> Self { + let (queue, filters) = { + let tests = tests.lock(); + as_queue_and_filters(params, &tests) + }; + + Self { + id: params.id, + kind: params.kind.clone(), + filters, + queue, + tests, + token: CancellationToken::new(), + workspace_settings, + } + } + + /// Provide the tests of a test run as an enqueued module which can be sent + /// to the client to indicate tests are enqueued for testing. + pub fn as_enqueued(&self) -> Vec { + let tests = self.tests.lock(); + self + .queue + .iter() + .map(|s| { + let ids = if let Some(test_definitions) = tests.get(s) { + if let Some(filter) = self.filters.get(s) { + filter.as_ids(test_definitions) + } else { + test_definitions + .discovered + .iter() + .map(|test| test.id.clone()) + .collect() + } + } else { + Vec::new() + }; + lsp_custom::EnqueuedTestModule { + text_document: lsp::TextDocumentIdentifier { uri: s.clone() }, + ids, + } + }) + .collect() + } + + /// If being executed, cancel the test. + pub fn cancel(&self) { + self.token.cancel(); + } + + /// Execute the tests, dispatching progress notifications to the client. + pub async fn exec( + &self, + client: &Client, + maybe_root_uri: Option<&ModuleSpecifier>, + ) -> Result<(), AnyError> { + let args = self.get_args(); + lsp_log!("Executing test run with arguments: {}", args.join(" ")); + let flags = flags::flags_from_vec(args)?; + let ps = proc_state::ProcState::build(Arc::new(flags)).await?; + let permissions = + Permissions::from_options(&ps.flags.permissions_options()); + test::check_specifiers( + &ps, + permissions.clone(), + self + .queue + .iter() + .map(|s| (s.clone(), test::TestMode::Executable)) + .collect(), + emit::TypeLib::DenoWindow, + ) + .await?; + + let (sender, mut receiver) = mpsc::unbounded_channel::(); + + let (concurrent_jobs, fail_fast) = + if let flags::DenoSubcommand::Test(test_flags) = &ps.flags.subcommand { + ( + test_flags.concurrent_jobs.into(), + test_flags.fail_fast.map(|count| count.into()), + ) + } else { + unreachable!("Should always be Test subcommand."); + }; + + let mut queue = self.queue.iter().collect::>(); + queue.sort(); + + let join_handles = queue.into_iter().map(move |specifier| { + let specifier = specifier.clone(); + let ps = ps.clone(); + let permissions = permissions.clone(); + let sender = sender.clone(); + let options = self.filters.get(&specifier).map(|f| f.as_test_options()); + let token = self.token.clone(); + + tokio::task::spawn_blocking(move || { + let future = test_specifier( + ps, + permissions, + specifier, + test::TestMode::Executable, + sender, + token, + options, + ); + + run_basic(future) + }) + }); + + let join_stream = stream::iter(join_handles) + .buffer_unordered(concurrent_jobs) + .collect::, tokio::task::JoinError>>>(); + + let mut reporter: Box = + Box::new(LspTestReporter::new( + self, + client.clone(), + maybe_root_uri, + self.tests.clone(), + )); + + let handler = { + tokio::task::spawn(async move { + let earlier = Instant::now(); + let mut summary = test::TestSummary::new(); + let mut used_only = false; + + while let Some(event) = receiver.recv().await { + match event { + test::TestEvent::Plan(plan) => { + summary.total += plan.total; + summary.filtered_out += plan.filtered_out; + + if plan.used_only { + used_only = true; + } + + reporter.report_plan(&plan); + } + test::TestEvent::Wait(description) => { + reporter.report_wait(&description); + } + test::TestEvent::Output(output) => { + reporter.report_output(&output); + } + test::TestEvent::Result(description, result, elapsed) => { + match &result { + test::TestResult::Ok => summary.passed += 1, + test::TestResult::Ignored => summary.ignored += 1, + test::TestResult::Failed(error) => { + summary.failed += 1; + summary.failures.push((description.clone(), error.clone())); + } + } + + reporter.report_result(&description, &result, elapsed); + } + test::TestEvent::StepWait(description) => { + reporter.report_step_wait(&description); + } + test::TestEvent::StepResult(description, result, duration) => { + match &result { + test::TestStepResult::Ok => { + summary.passed_steps += 1; + } + test::TestStepResult::Ignored => { + summary.ignored_steps += 1; + } + test::TestStepResult::Failed(_) => { + summary.failed_steps += 1; + } + test::TestStepResult::Pending(_) => { + summary.pending_steps += 1; + } + } + reporter.report_step_result(&description, &result, duration); + } + } + + if let Some(count) = fail_fast { + if summary.failed >= count { + break; + } + } + } + + let elapsed = Instant::now().duration_since(earlier); + reporter.report_summary(&summary, &elapsed); + + if used_only { + return Err(anyhow!( + "Test failed because the \"only\" option was used" + )); + } + + if summary.failed > 0 { + return Err(anyhow!("Test failed")); + } + + Ok(()) + }) + }; + + let (join_results, result) = future::join(join_stream, handler).await; + + // propagate any errors + for join_result in join_results { + join_result??; + } + + result??; + + Ok(()) + } + + fn get_args(&self) -> Vec<&str> { + let mut args = vec!["deno", "test"]; + args.extend( + self + .workspace_settings + .testing + .args + .iter() + .map(|s| s.as_str()), + ); + if self.workspace_settings.unstable && !args.contains(&"--unstable") { + args.push("--unstable"); + } + if let Some(config) = &self.workspace_settings.config { + if !args.contains(&"--config") && !args.contains(&"-c") { + args.push("--config"); + args.push(config.as_str()); + } + } + if let Some(import_map) = &self.workspace_settings.import_map { + if !args.contains(&"--import-map") { + args.push("--import-map"); + args.push(import_map.as_str()); + } + } + if self.kind == lsp_custom::TestRunKind::Debug + && !args.contains(&"--inspect") + && !args.contains(&"--inspect-brk") + { + args.push("--inspect"); + } + args + } +} + +#[derive(Debug, PartialEq)] +enum TestOrTestStepDescription { + TestDescription(test::TestDescription), + TestStepDescription(test::TestStepDescription), +} + +impl From<&test::TestDescription> for TestOrTestStepDescription { + fn from(desc: &test::TestDescription) -> Self { + Self::TestDescription(desc.clone()) + } +} + +impl From<&test::TestStepDescription> for TestOrTestStepDescription { + fn from(desc: &test::TestStepDescription) -> Self { + Self::TestStepDescription(desc.clone()) + } +} + +impl From<&TestOrTestStepDescription> for lsp_custom::TestIdentifier { + fn from(desc: &TestOrTestStepDescription) -> lsp_custom::TestIdentifier { + match desc { + TestOrTestStepDescription::TestDescription(test_desc) => test_desc.into(), + TestOrTestStepDescription::TestStepDescription(test_step_desc) => { + test_step_desc.into() + } + } + } +} + +impl From<&TestOrTestStepDescription> for lsp_custom::TestData { + fn from(desc: &TestOrTestStepDescription) -> Self { + match desc { + TestOrTestStepDescription::TestDescription(desc) => desc.into(), + TestOrTestStepDescription::TestStepDescription(desc) => desc.into(), + } + } +} + +impl From<&test::TestDescription> for lsp_custom::TestData { + fn from(desc: &test::TestDescription) -> Self { + let id = checksum::gen(&[desc.origin.as_bytes(), desc.name.as_bytes()]); + + Self { + id, + label: desc.name.clone(), + steps: Default::default(), + range: None, + } + } +} + +impl From<&test::TestDescription> for lsp_custom::TestIdentifier { + fn from(desc: &test::TestDescription) -> Self { + let uri = ModuleSpecifier::parse(&desc.origin).unwrap(); + let id = Some(checksum::gen(&[ + desc.origin.as_bytes(), + desc.name.as_bytes(), + ])); + + Self { + text_document: lsp::TextDocumentIdentifier { uri }, + id, + step_id: None, + } + } +} + +impl From<&test::TestStepDescription> for lsp_custom::TestData { + fn from(desc: &test::TestStepDescription) -> Self { + let id = checksum::gen(&[ + desc.test.origin.as_bytes(), + &desc.level.to_be_bytes(), + desc.name.as_bytes(), + ]); + + Self { + id, + label: desc.name.clone(), + steps: Default::default(), + range: None, + } + } +} + +impl From<&test::TestStepDescription> for lsp_custom::TestIdentifier { + fn from(desc: &test::TestStepDescription) -> Self { + let uri = ModuleSpecifier::parse(&desc.test.origin).unwrap(); + let id = Some(checksum::gen(&[ + desc.test.origin.as_bytes(), + desc.test.name.as_bytes(), + ])); + let step_id = Some(checksum::gen(&[ + desc.test.origin.as_bytes(), + &desc.level.to_be_bytes(), + desc.name.as_bytes(), + ])); + + Self { + text_document: lsp::TextDocumentIdentifier { uri }, + id, + step_id, + } + } +} + +struct LspTestReporter { + client: Client, + current_origin: Option, + maybe_root_uri: Option, + id: u32, + stack: HashMap>, + tests: Arc>>, +} + +impl LspTestReporter { + fn new( + run: &TestRun, + client: Client, + maybe_root_uri: Option<&ModuleSpecifier>, + tests: Arc>>, + ) -> Self { + Self { + client, + current_origin: None, + maybe_root_uri: maybe_root_uri.cloned(), + id: run.id, + stack: HashMap::new(), + tests, + } + } + + fn add_step(&self, desc: &test::TestStepDescription) { + if let Ok(specifier) = ModuleSpecifier::parse(&desc.test.origin) { + let mut tests = self.tests.lock(); + let entry = + tests + .entry(specifier.clone()) + .or_insert_with(|| TestDefinitions { + discovered: Default::default(), + injected: Default::default(), + script_version: "1".to_string(), + }); + let mut prev: lsp_custom::TestData = desc.into(); + if let Some(stack) = self.stack.get(&desc.test.origin) { + for item in stack.iter().rev() { + let mut data: lsp_custom::TestData = item.into(); + data.steps = Some(vec![prev]); + prev = data; + } + entry.injected.push(prev.clone()); + let label = if let Some(root) = &self.maybe_root_uri { + specifier.as_str().replace(root.as_str(), "") + } else { + specifier + .path_segments() + .and_then(|s| s.last().map(|s| s.to_string())) + .unwrap_or_else(|| "".to_string()) + }; + self + .client + .send_test_notification(TestingNotification::Module( + lsp_custom::TestModuleNotificationParams { + text_document: lsp::TextDocumentIdentifier { uri: specifier }, + kind: lsp_custom::TestModuleNotificationKind::Insert, + label, + tests: vec![prev], + }, + )); + } + } + } + + /// Add a test which is being reported from the test runner but was not + /// statically identified + fn add_test(&self, desc: &test::TestDescription) { + if let Ok(specifier) = ModuleSpecifier::parse(&desc.origin) { + let mut tests = self.tests.lock(); + let entry = + tests + .entry(specifier.clone()) + .or_insert_with(|| TestDefinitions { + discovered: Default::default(), + injected: Default::default(), + script_version: "1".to_string(), + }); + entry.injected.push(desc.into()); + let label = if let Some(root) = &self.maybe_root_uri { + specifier.as_str().replace(root.as_str(), "") + } else { + specifier + .path_segments() + .and_then(|s| s.last().map(|s| s.to_string())) + .unwrap_or_else(|| "".to_string()) + }; + self + .client + .send_test_notification(TestingNotification::Module( + lsp_custom::TestModuleNotificationParams { + text_document: lsp::TextDocumentIdentifier { uri: specifier }, + kind: lsp_custom::TestModuleNotificationKind::Insert, + label, + tests: vec![desc.into()], + }, + )); + } + } + + fn progress(&self, message: lsp_custom::TestRunProgressMessage) { + self + .client + .send_test_notification(TestingNotification::Progress( + lsp_custom::TestRunProgressParams { + id: self.id, + message, + }, + )); + } + + fn includes_step(&self, desc: &test::TestStepDescription) -> bool { + if let Ok(specifier) = ModuleSpecifier::parse(&desc.test.origin) { + let tests = self.tests.lock(); + if let Some(test_definitions) = tests.get(&specifier) { + return test_definitions + .get_step_by_name(&desc.test.name, desc.level, &desc.name) + .is_some(); + } + } + false + } + + fn includes_test(&self, desc: &test::TestDescription) -> bool { + if let Ok(specifier) = ModuleSpecifier::parse(&desc.origin) { + let tests = self.tests.lock(); + if let Some(test_definitions) = tests.get(&specifier) { + return test_definitions.get_by_name(&desc.name).is_some(); + } + } + false + } +} + +impl test::TestReporter for LspTestReporter { + fn report_plan(&mut self, _plan: &test::TestPlan) { + // there is nothing to do on report_plan + } + + fn report_wait(&mut self, desc: &test::TestDescription) { + if !self.includes_test(desc) { + self.add_test(desc); + } + self.current_origin = Some(desc.origin.clone()); + let test: lsp_custom::TestIdentifier = desc.into(); + let stack = self.stack.entry(desc.origin.clone()).or_default(); + assert!(stack.is_empty()); + stack.push(desc.into()); + self.progress(lsp_custom::TestRunProgressMessage::Started { test }); + } + + fn report_output(&mut self, output: &test::TestOutput) { + let test = self.current_origin.as_ref().and_then(|origin| { + self + .stack + .get(origin) + .and_then(|v| v.last().map(|td| td.into())) + }); + match output { + test::TestOutput::Console(value) => { + self.progress(lsp_custom::TestRunProgressMessage::Output { + value: value.replace('\n', "\r\n"), + test, + // TODO(@kitsonk) test output should include a location + location: None, + }) + } + } + } + + fn report_result( + &mut self, + desc: &test::TestDescription, + result: &test::TestResult, + elapsed: u64, + ) { + let stack = self.stack.entry(desc.origin.clone()).or_default(); + assert_eq!(stack.len(), 1); + assert_eq!(stack.pop(), Some(desc.into())); + self.current_origin = None; + match result { + test::TestResult::Ok => { + self.progress(lsp_custom::TestRunProgressMessage::Passed { + test: desc.into(), + duration: Some(elapsed as u32), + }) + } + test::TestResult::Ignored => { + self.progress(lsp_custom::TestRunProgressMessage::Skipped { + test: desc.into(), + }) + } + test::TestResult::Failed(message) => { + self.progress(lsp_custom::TestRunProgressMessage::Failed { + test: desc.into(), + messages: as_test_messages(message, false), + duration: Some(elapsed as u32), + }) + } + } + } + + fn report_step_wait(&mut self, desc: &test::TestStepDescription) { + if !self.includes_step(desc) { + self.add_step(desc); + } + let test: lsp_custom::TestIdentifier = desc.into(); + let stack = self.stack.entry(desc.test.origin.clone()).or_default(); + self.current_origin = Some(desc.test.origin.clone()); + assert!(!stack.is_empty()); + stack.push(desc.into()); + self.progress(lsp_custom::TestRunProgressMessage::Started { test }); + } + + fn report_step_result( + &mut self, + desc: &test::TestStepDescription, + result: &test::TestStepResult, + elapsed: u64, + ) { + let stack = self.stack.entry(desc.test.origin.clone()).or_default(); + assert_eq!(stack.pop(), Some(desc.into())); + match result { + test::TestStepResult::Ok => { + self.progress(lsp_custom::TestRunProgressMessage::Passed { + test: desc.into(), + duration: Some(elapsed as u32), + }) + } + test::TestStepResult::Ignored => { + self.progress(lsp_custom::TestRunProgressMessage::Skipped { + test: desc.into(), + }) + } + test::TestStepResult::Failed(message) => { + let messages = if let Some(message) = message { + as_test_messages(message, false) + } else { + vec![] + }; + self.progress(lsp_custom::TestRunProgressMessage::Failed { + test: desc.into(), + messages, + duration: Some(elapsed as u32), + }) + } + test::TestStepResult::Pending(_) => { + self.progress(lsp_custom::TestRunProgressMessage::Enqueued { + test: desc.into(), + }) + } + } + } + + fn report_summary( + &mut self, + _summary: &test::TestSummary, + _elapsed: &Duration, + ) { + // there is nothing to do on report_summary + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lsp::testing::collectors::tests::new_span; + + #[test] + fn test_as_queue_and_filters() { + let specifier = ModuleSpecifier::parse("file:///a/file.ts").unwrap(); + let params = lsp_custom::TestRunRequestParams { + id: 1, + kind: lsp_custom::TestRunKind::Run, + include: Some(vec![lsp_custom::TestIdentifier { + text_document: lsp::TextDocumentIdentifier { + uri: specifier.clone(), + }, + id: None, + step_id: None, + }]), + exclude: Some(vec![lsp_custom::TestIdentifier { + text_document: lsp::TextDocumentIdentifier { + uri: specifier.clone(), + }, + id: Some( + "69d9fe87f64f5b66cb8b631d4fd2064e8224b8715a049be54276c42189ff8f9f" + .to_string(), + ), + step_id: None, + }]), + }; + let mut tests = HashMap::new(); + let test_def_a = TestDefinition { + id: "0b7c6bf3cd617018d33a1bf982a08fe088c5bb54fcd5eb9e802e7c137ec1af94" + .to_string(), + level: 0, + name: "test a".to_string(), + span: new_span(420, 424, 1), + steps: None, + }; + let test_def_b = TestDefinition { + id: "69d9fe87f64f5b66cb8b631d4fd2064e8224b8715a049be54276c42189ff8f9f" + .to_string(), + level: 0, + name: "test b".to_string(), + span: new_span(480, 481, 1), + steps: None, + }; + let test_definitions = TestDefinitions { + discovered: vec![test_def_a, test_def_b.clone()], + injected: vec![], + script_version: "1".to_string(), + }; + tests.insert(specifier.clone(), test_definitions.clone()); + let (queue, filters) = as_queue_and_filters(¶ms, &tests); + assert_eq!(json!(queue), json!([specifier])); + let mut exclude = HashMap::new(); + exclude.insert( + "69d9fe87f64f5b66cb8b631d4fd2064e8224b8715a049be54276c42189ff8f9f" + .to_string(), + test_def_b, + ); + let maybe_filter = filters.get(&specifier); + assert!(maybe_filter.is_some()); + let filter = maybe_filter.unwrap(); + assert_eq!( + filter, + &TestFilter { + maybe_include: None, + maybe_exclude: Some(exclude), + } + ); + assert_eq!( + filter.as_ids(&test_definitions), + vec![ + "0b7c6bf3cd617018d33a1bf982a08fe088c5bb54fcd5eb9e802e7c137ec1af94" + .to_string() + ] + ); + assert_eq!( + filter.as_test_options(), + json!({ + "filter": { + "include": null, + "exclude": vec!["test b"], + } + }) + ); + } +} diff --git a/cli/lsp/testing/lsp_custom.rs b/cli/lsp/testing/lsp_custom.rs new file mode 100644 index 0000000000..c1182b04e3 --- /dev/null +++ b/cli/lsp/testing/lsp_custom.rs @@ -0,0 +1,186 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use deno_core::serde::Deserialize; +use deno_core::serde::Serialize; +use lspower::lsp; + +pub const TEST_RUN_CANCEL_REQUEST: &str = "deno/testRunCancel"; +pub const TEST_RUN_REQUEST: &str = "deno/testRun"; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnqueuedTestModule { + pub text_document: lsp::TextDocumentIdentifier, + pub ids: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestData { + /// The unique ID of the test + pub id: String, + /// The human readable test to display for the test. + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub steps: Option>, + /// The range where the test is located. + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TestModuleNotificationKind { + /// The test module notification represents an insertion of tests, not + /// replacement of the test children. + Insert, + /// The test module notification represents a replacement of any tests within + /// the test module. + Replace, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestModuleNotificationParams { + /// The text document that the notification relates to. + pub text_document: lsp::TextDocumentIdentifier, + /// Indicates what kind of notification this represents. + pub kind: TestModuleNotificationKind, + /// The human readable text to display for the test module. + pub label: String, + /// The tests identified in the module. + pub tests: Vec, +} + +pub enum TestModuleNotification {} + +impl lsp::notification::Notification for TestModuleNotification { + type Params = TestModuleNotificationParams; + + const METHOD: &'static str = "deno/testModule"; +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestModuleDeleteNotificationParams { + /// The text document that the notification relates to. + pub text_document: lsp::TextDocumentIdentifier, +} + +pub enum TestModuleDeleteNotification {} + +impl lsp::notification::Notification for TestModuleDeleteNotification { + type Params = TestModuleDeleteNotificationParams; + + const METHOD: &'static str = "deno/testModuleDelete"; +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TestRunKind { + // The run profile is just to execute the tests + Run, + // The tests should be run and debugged, currently not implemented + Debug, + // The tests should be run, collecting and reporting coverage information, + // currently not implemented + Coverage, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestRunRequestParams { + pub id: u32, + pub kind: TestRunKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub exclude: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub include: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestRunCancelParams { + pub id: u32, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestRunProgressParams { + pub id: u32, + pub message: TestRunProgressMessage, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TestIdentifier { + /// The module identifier which contains the test. + pub text_document: lsp::TextDocumentIdentifier, + /// An optional string identifying the individual test. If not present, then + /// it identifies all the tests associated with the module. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// An optional structure identifying a step of the test. If not present, then + /// no step is identified. + #[serde(skip_serializing_if = "Option::is_none")] + pub step_id: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum TestRunProgressMessage { + Enqueued { + test: TestIdentifier, + }, + Started { + test: TestIdentifier, + }, + Skipped { + test: TestIdentifier, + }, + Failed { + test: TestIdentifier, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + }, + Errored { + test: TestIdentifier, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + }, + Passed { + test: TestIdentifier, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + }, + Output { + value: String, + #[serde(skip_serializing_if = "Option::is_none")] + test: Option, + #[serde(skip_serializing_if = "Option::is_none")] + location: Option, + }, + End, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestMessage { + pub message: lsp::MarkupContent, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub actual_output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +pub enum TestRunProgressNotification {} + +impl lsp::notification::Notification for TestRunProgressNotification { + type Params = TestRunProgressParams; + + const METHOD: &'static str = "deno/testRunProgress"; +} diff --git a/cli/lsp/testing/mod.rs b/cli/lsp/testing/mod.rs new file mode 100644 index 0000000000..cbd49724ee --- /dev/null +++ b/cli/lsp/testing/mod.rs @@ -0,0 +1,11 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +mod collectors; +mod definitions; +mod execution; +pub mod lsp_custom; +mod server; + +pub use lsp_custom::TEST_RUN_CANCEL_REQUEST; +pub use lsp_custom::TEST_RUN_REQUEST; +pub use server::TestServer; diff --git a/cli/lsp/testing/server.rs b/cli/lsp/testing/server.rs new file mode 100644 index 0000000000..b176fea68b --- /dev/null +++ b/cli/lsp/testing/server.rs @@ -0,0 +1,219 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use super::collectors::TestCollector; +use super::definitions::TestDefinitions; +use super::execution::TestRun; +use super::lsp_custom; + +use crate::lsp::client::Client; +use crate::lsp::client::TestingNotification; +use crate::lsp::config; +use crate::lsp::language_server::StateSnapshot; +use crate::lsp::performance::Performance; + +use deno_ast::swc::visit::VisitWith; +use deno_core::error::AnyError; +use deno_core::parking_lot::Mutex; +use deno_core::serde_json::json; +use deno_core::serde_json::Value; +use deno_core::ModuleSpecifier; +use deno_runtime::tokio_util::create_basic_runtime; +use lspower::jsonrpc::Error as LspError; +use lspower::jsonrpc::Result as LspResult; +use lspower::lsp; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::Arc; +use std::thread; +use tokio::sync::mpsc; + +fn as_delete_notification(uri: ModuleSpecifier) -> TestingNotification { + TestingNotification::DeleteModule( + lsp_custom::TestModuleDeleteNotificationParams { + text_document: lsp::TextDocumentIdentifier { uri }, + }, + ) +} + +/// The main structure which handles requests and sends notifications related +/// to the Testing API. +#[derive(Debug)] +pub struct TestServer { + client: Client, + performance: Arc, + /// A channel for handling run requests from the client + run_channel: mpsc::UnboundedSender, + /// A map of run ids to test runs + runs: Arc>>, + /// Tests that are discovered from a versioned document + tests: Arc>>, + /// A channel for requesting that changes to documents be statically analyzed + /// for tests + update_channel: mpsc::UnboundedSender>, +} + +impl TestServer { + pub fn new( + client: Client, + performance: Arc, + maybe_root_uri: Option, + ) -> Self { + let tests: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + + let (update_channel, mut update_rx) = + mpsc::unbounded_channel::>(); + let (run_channel, mut run_rx) = mpsc::unbounded_channel::(); + + let server = Self { + client, + performance, + run_channel, + runs: Default::default(), + tests, + update_channel, + }; + + let tests = server.tests.clone(); + let client = server.client.clone(); + let performance = server.performance.clone(); + let mru = maybe_root_uri.clone(); + let _update_join_handle = thread::spawn(move || { + let runtime = create_basic_runtime(); + + runtime.block_on(async { + loop { + match update_rx.recv().await { + None => break, + Some(snapshot) => { + let mark = performance.mark("testing_update", None::<()>); + let mut tests = tests.lock(); + // we create a list of test modules we currently are tracking + // eliminating any we go over when iterating over the document + let mut keys: HashSet = + tests.keys().cloned().collect(); + for document in snapshot.documents.documents(false, true) { + let specifier = document.specifier(); + keys.remove(specifier); + let script_version = document.script_version(); + let valid = if let Some(test) = tests.get(specifier) { + test.script_version == script_version + } else { + false + }; + if !valid { + if let Some(Ok(parsed_source)) = + document.maybe_parsed_source() + { + let mut collector = TestCollector::new(specifier.clone()); + parsed_source.module().visit_with(&mut collector); + let test_definitions = TestDefinitions { + discovered: collector.take(), + injected: Default::default(), + script_version, + }; + if !test_definitions.discovered.is_empty() { + client.send_test_notification( + test_definitions.as_notification( + specifier, + mru.as_ref(), + parsed_source.source(), + ), + ); + } + tests.insert(specifier.clone(), test_definitions); + } + } + } + for key in keys { + client.send_test_notification(as_delete_notification(key)); + } + performance.measure(mark); + } + } + } + }) + }); + + let client = server.client.clone(); + let runs = server.runs.clone(); + let _run_join_handle = thread::spawn(move || { + let runtime = create_basic_runtime(); + + runtime.block_on(async { + loop { + match run_rx.recv().await { + None => break, + Some(id) => { + let maybe_run = { + let runs = runs.lock(); + runs.get(&id).cloned() + }; + if let Some(run) = maybe_run { + match run.exec(&client, maybe_root_uri.as_ref()).await { + Ok(_) => (), + Err(err) => { + client.show_message(lsp::MessageType::ERROR, err).await; + } + } + client.send_test_notification(TestingNotification::Progress( + lsp_custom::TestRunProgressParams { + id, + message: lsp_custom::TestRunProgressMessage::End, + }, + )); + runs.lock().remove(&id); + } + } + } + } + }) + }); + + server + } + + fn enqueue_run(&self, id: u32) -> Result<(), AnyError> { + self.run_channel.send(id).map_err(|err| err.into()) + } + + /// A request from the client to cancel a test run. + pub fn run_cancel_request( + &self, + params: lsp_custom::TestRunCancelParams, + ) -> LspResult> { + if let Some(run) = self.runs.lock().get(¶ms.id) { + run.cancel(); + Ok(Some(json!(true))) + } else { + Ok(Some(json!(false))) + } + } + + /// A request from the client to start a test run. + pub fn run_request( + &self, + params: lsp_custom::TestRunRequestParams, + workspace_settings: config::WorkspaceSettings, + ) -> LspResult> { + let test_run = + { TestRun::new(¶ms, self.tests.clone(), workspace_settings) }; + let enqueued = test_run.as_enqueued(); + { + let mut runs = self.runs.lock(); + runs.insert(params.id, test_run); + } + self.enqueue_run(params.id).map_err(|err| { + log::error!("cannot enqueue run: {}", err); + LspError::internal_error() + })?; + Ok(Some(json!({ "enqueued": enqueued }))) + } + + pub(crate) fn update( + &self, + snapshot: Arc, + ) -> Result<(), AnyError> { + self.update_channel.send(snapshot).map_err(|err| err.into()) + } +} diff --git a/cli/tests/integration/lsp_tests.rs b/cli/tests/integration/lsp_tests.rs index 63695634aa..784dc82630 100644 --- a/cli/tests/integration/lsp_tests.rs +++ b/cli/tests/integration/lsp_tests.rs @@ -38,7 +38,7 @@ fn load_fixture_str(path: &str) -> String { fn init(init_path: &str) -> LspClient { let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", load_fixture(init_path)) .unwrap(); @@ -247,7 +247,7 @@ fn lsp_init_tsconfig() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -290,7 +290,7 @@ fn lsp_tsconfig_types() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -350,7 +350,7 @@ fn lsp_triple_slash_types() { params.root_uri = Some(Url::from_file_path(temp_dir.path()).unwrap()); let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -397,7 +397,7 @@ fn lsp_import_map() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -516,7 +516,7 @@ fn lsp_import_map_config_file() { .unwrap(); let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -599,7 +599,7 @@ fn lsp_deno_task() { params.root_uri = Some(Url::from_file_path(workspace_root).unwrap()); let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -725,7 +725,7 @@ fn lsp_import_map_import_completions() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -1085,7 +1085,7 @@ fn lsp_workspace_enable_paths() { }]); let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -2240,7 +2240,7 @@ fn lsp_format_exclude_with_config() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -2292,7 +2292,7 @@ fn lsp_format_exclude_default_config() { params.root_uri = Some(Url::from_file_path(workspace_root.clone()).unwrap()); let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -3824,7 +3824,7 @@ fn lsp_cache_location() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -3945,7 +3945,7 @@ fn lsp_tls_cert() { params.root_uri = Some(Url::from_file_path(testdata_path()).unwrap()); let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -4426,7 +4426,7 @@ fn lsp_performance() { .unwrap(); assert!(maybe_err.is_none()); if let Some(res) = maybe_res { - assert_eq!(res.averages.len(), 13); + assert_eq!(res.averages.len(), 14); } else { panic!("unexpected result"); } @@ -4622,7 +4622,7 @@ fn lsp_format_with_config() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -5101,7 +5101,7 @@ fn lsp_lint_with_config() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -5134,7 +5134,7 @@ fn lsp_lint_exclude_with_config() { } let deno_exe = deno_exe_path(); - let mut client = LspClient::new(&deno_exe).unwrap(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); client .write_request::<_, _, Value>("initialize", params) .unwrap(); @@ -5236,3 +5236,230 @@ export function B() { ); shutdown(&mut client); } + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct TestData { + id: String, + label: String, + steps: Option>, + range: Option, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +enum TestModuleNotificationKind { + Insert, + Replace, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TestModuleNotificationParams { + text_document: lsp::TextDocumentIdentifier, + kind: TestModuleNotificationKind, + label: String, + tests: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnqueuedTestModule { + text_document: lsp::TextDocumentIdentifier, + ids: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TestRunResponseParams { + enqueued: Vec, +} + +#[test] +fn lsp_testing_api() { + let mut params: lsp::InitializeParams = + serde_json::from_value(load_fixture("initialize_params.json")).unwrap(); + let temp_dir = TempDir::new().unwrap(); + + let root_specifier = + ensure_directory_specifier(Url::from_file_path(temp_dir.path()).unwrap()); + + let module_path = temp_dir.path().join("./test.ts"); + let specifier = ModuleSpecifier::from_file_path(&module_path).unwrap(); + let contents = r#" +Deno.test({ + name: "test a", + fn() { + console.log("test a"); + } +}); +"#; + fs::write(&module_path, &contents).unwrap(); + fs::write(temp_dir.path().join("./deno.jsonc"), r#"{}"#).unwrap(); + + params.root_uri = Some(root_specifier); + + let deno_exe = deno_exe_path(); + let mut client = LspClient::new(&deno_exe, false).unwrap(); + client + .write_request::<_, _, Value>("initialize", params) + .unwrap(); + + client.write_notification("initialized", json!({})).unwrap(); + + client + .write_notification( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": specifier, + "languageId": "typescript", + "version": 1, + "text": contents, + } + }), + ) + .unwrap(); + + handle_configuration_request( + &mut client, + json!([{ + "enable": true, + "codeLens": { + "test": true + } + }]), + ); + + for _ in 0..4 { + let result = client.read_notification::(); + assert!(result.is_ok()); + let (method, notification) = result.unwrap(); + if method.as_str() == "deno/testModule" { + let params: TestModuleNotificationParams = + serde_json::from_value(notification.unwrap()).unwrap(); + assert_eq!(params.text_document.uri, specifier); + assert_eq!(params.kind, TestModuleNotificationKind::Replace); + assert_eq!(params.label, "test.ts"); + assert_eq!(params.tests.len(), 1); + let test = ¶ms.tests[0]; + assert_eq!(test.label, "test a"); + assert!(test.steps.is_none()); + assert_eq!( + test.range, + Some(lsp::Range { + start: lsp::Position { + line: 1, + character: 5, + }, + end: lsp::Position { + line: 1, + character: 9, + } + }) + ); + } + } + + let (maybe_res, maybe_err) = client + .write_request::<_, _, TestRunResponseParams>( + "deno/testRun", + json!({ + "id": 1, + "kind": "run", + }), + ) + .unwrap(); + assert!(maybe_err.is_none()); + assert!(maybe_res.is_some()); + let res = maybe_res.unwrap(); + assert_eq!(res.enqueued.len(), 1); + assert_eq!(res.enqueued[0].text_document.uri, specifier); + assert_eq!(res.enqueued[0].ids.len(), 1); + let id = res.enqueued[0].ids[0].clone(); + + let res = client.read_notification::(); + assert!(res.is_ok()); + let (method, notification) = res.unwrap(); + assert_eq!(method, "deno/testRunProgress"); + assert_eq!( + notification, + Some(json!({ + "id": 1, + "message": { + "type": "started", + "test": { + "textDocument": { + "uri": specifier, + }, + "id": id, + }, + } + })) + ); + + let res = client.read_notification::(); + assert!(res.is_ok()); + let (method, notification) = res.unwrap(); + assert_eq!(method, "deno/testRunProgress"); + assert_eq!( + notification, + Some(json!({ + "id": 1, + "message": { + "type": "output", + "value": "test a\r\n", + "test": { + "textDocument": { + "uri": specifier, + }, + "id": id, + }, + } + })) + ); + + let res = client.read_notification::(); + assert!(res.is_ok()); + let (method, notification) = res.unwrap(); + assert_eq!(method, "deno/testRunProgress"); + let notification = notification.unwrap(); + let obj = notification.as_object().unwrap(); + assert_eq!(obj.get("id"), Some(&json!(1))); + let message = obj.get("message").unwrap().as_object().unwrap(); + match message.get("type").and_then(|v| v.as_str()) { + Some("passed") => { + assert_eq!( + message.get("test"), + Some(&json!({ + "textDocument": { + "uri": specifier + }, + "id": id, + })) + ); + assert!(message.contains_key("duration")); + + let res = client.read_notification::(); + assert!(res.is_ok()); + let (method, notification) = res.unwrap(); + assert_eq!(method, "deno/testRunProgress"); + assert_eq!( + notification, + Some(json!({ + "id": 1, + "message": { + "type": "end", + } + })) + ); + } + // sometimes on windows, the messages come out of order, but it actually is + // working, so if we do get the end before the passed, we will simply let + // the test pass + Some("end") => (), + _ => panic!("unexpected message {}", json!(notification)), + } + + shutdown(&mut client); +} diff --git a/cli/tests/testdata/lsp/initialize_params.json b/cli/tests/testdata/lsp/initialize_params.json index 9fd197fe41..b076f3b175 100644 --- a/cli/tests/testdata/lsp/initialize_params.json +++ b/cli/tests/testdata/lsp/initialize_params.json @@ -14,7 +14,7 @@ "references": true, "test": true }, - "config": "", + "config": null, "importMap": null, "lint": true, "suggest": { @@ -26,6 +26,12 @@ "hosts": {} } }, + "testing": { + "args": [ + "--allow-all" + ], + "enable": true + }, "tlsCertificate": null, "unsafelyIgnoreCertificateErrors": null, "unstable": false @@ -63,6 +69,9 @@ "workspace": { "configuration": true, "workspaceFolders": true + }, + "experimental": { + "testingApi": true } } } diff --git a/cli/tools/test.rs b/cli/tools/test.rs index e29d9a2201..242abab1b0 100644 --- a/cli/tools/test.rs +++ b/cli/tools/test.rs @@ -58,7 +58,7 @@ use tokio::sync::mpsc::UnboundedSender; /// The test mode is used to determine how a specifier is to be tested. #[derive(Debug, Clone, PartialEq)] -enum TestMode { +pub enum TestMode { /// Test as documentation, type-checking fenced code blocks. Documentation, /// Test as an executable module, loading the module into the isolate and running each test it @@ -163,7 +163,7 @@ struct TestSpecifierOptions { } impl TestSummary { - fn new() -> TestSummary { + pub fn new() -> TestSummary { TestSummary { total: 0, passed: 0, @@ -188,7 +188,7 @@ impl TestSummary { } } -trait TestReporter { +pub trait TestReporter { fn report_plan(&mut self, plan: &TestPlan); fn report_wait(&mut self, description: &TestDescription); fn report_output(&mut self, output: &TestOutput); @@ -718,7 +718,7 @@ async fn fetch_inline_files( } /// Type check a collection of module and document specifiers. -async fn check_specifiers( +pub async fn check_specifiers( ps: &ProcState, permissions: Permissions, specifiers: Vec<(ModuleSpecifier, TestMode)>, diff --git a/runtime/js/40_testing.js b/runtime/js/40_testing.js index 3e4a57df1c..abbef2ae44 100644 --- a/runtime/js/40_testing.js +++ b/runtime/js/40_testing.js @@ -750,23 +750,37 @@ return inspectArgs([error]); } + /** + * @param {string | { include?: string[], exclude?: string[] }} filter + * @returns {(def: { name: string }) => boolean} + */ function createTestFilter(filter) { + if (!filter) { + return () => true; + } + + const regex = + typeof filter === "string" && StringPrototypeStartsWith(filter, "/") && + StringPrototypeEndsWith(filter, "/") + ? new RegExp(StringPrototypeSlice(filter, 1, filter.length - 1)) + : undefined; + + const filterIsObject = filter != null && typeof filter === "object"; + return (def) => { - if (filter) { - if ( - StringPrototypeStartsWith(filter, "/") && - StringPrototypeEndsWith(filter, "/") - ) { - const regex = new RegExp( - StringPrototypeSlice(filter, 1, filter.length - 1), - ); - return RegExpPrototypeTest(regex, def.name); - } - - return StringPrototypeIncludes(def.name, filter); + if (regex) { + return RegExpPrototypeTest(regex, def.name); } - - return true; + if (filterIsObject) { + if (filter.include && !filter.include.includes(def.name)) { + return false; + } else if (filter.exclude && filter.exclude.includes(def.name)) { + return false; + } else { + return true; + } + } + return StringPrototypeIncludes(def.name, filter); }; } diff --git a/test_util/src/lsp.rs b/test_util/src/lsp.rs index 948dc4da64..9d5a74eafc 100644 --- a/test_util/src/lsp.rs +++ b/test_util/src/lsp.rs @@ -167,15 +167,18 @@ where } impl LspClient { - pub fn new(deno_exe: &Path) -> Result { + pub fn new(deno_exe: &Path, print_stderr: bool) -> Result { let deno_dir = new_deno_dir(); - let mut child = Command::new(deno_exe) + let mut command = Command::new(deno_exe); + command .env("DENO_DIR", deno_dir.path()) .arg("lsp") .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn()?; + .stdout(Stdio::piped()); + if !print_stderr { + command.stderr(Stdio::null()); + } + let mut child = command.spawn()?; let stdout = child.stdout.take().unwrap(); let reader = io::BufReader::new(stdout);