/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/lsp/testing/execution.rs
1 232 строки
41 KB
em
fix(lsp): honor --env-file from deno.json `test` task in test runner (#34905)
24 июн 2026, 09:12
Не верифицирован
24 июн 2026, 09:12
0a89dad
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use std::time::Instant; use deno_core::ModuleSpecifier; use deno_core::anyhow::anyhow; use deno_core::error::AnyError; use deno_core::error::JsError; use deno_core::futures::StreamExt; use deno_core::futures::future; use deno_core::futures::stream; use deno_core::parking_lot::RwLock; use deno_core::unsync::spawn; use deno_core::unsync::spawn_blocking; use deno_runtime::deno_permissions::Permissions; use deno_runtime::deno_permissions::PermissionsContainer; use deno_runtime::tokio_util::create_and_run_current_thread; use indexmap::IndexMap; use tokio_util::sync::CancellationToken; use tower_lsp::lsp_types as lsp; use super::definitions::TestDefinition; use super::definitions::TestModule; use super::lsp_custom; use super::server::TestServerTests; use crate::args::DenoSubcommand; use crate::args::flags_from_vec; use crate::args::parallelism_count; use crate::factory::CliFactory; use crate::lsp::client::Client; use crate::lsp::client::TestingNotification; use crate::lsp::config; use crate::lsp::logging::lsp_log; use crate::lsp::urls::uri_parse_unencoded; use crate::lsp::urls::uri_to_url; use crate::lsp::urls::url_to_uri; use crate::tools::test; use crate::tools::test::FailFastTracker; use crate::tools::test::TestFailure; use crate::tools::test::TestFailureFormatOptions; use crate::tools::test::create_test_event_channel; use crate::util::env::WatchEnvTracker; use crate::util::env::resolve_cwd_or_fallback; /// 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<ModuleSpecifier, (TestModule, String)>, ) -> ( HashSet<ModuleSpecifier>, HashMap<ModuleSpecifier, LspTestFilter>, ) { let mut queue: HashSet<ModuleSpecifier> = HashSet::new(); let mut filters: HashMap<ModuleSpecifier, LspTestFilter> = HashMap::new(); if let Some(include) = ¶ms.include { for item in include { let url = uri_to_url(&item.text_document.uri); if let Some((test_definitions, _)) = tests.get(&url) { queue.insert(url.clone()); if let Some(id) = &item.id && let Some(test) = test_definitions.get(id) { let filter = filters.entry(url).or_default(); if let Some(include) = filter.include.as_mut() { include.insert(test.id.clone(), test.clone()); } else { let mut include = HashMap::new(); include.insert(test.id.clone(), test.clone()); filter.include = Some(include); } } } } } else { queue.extend(tests.keys().cloned()); } for item in ¶ms.exclude { let url = uri_to_url(&item.text_document.uri); if let Some((test_definitions, _)) = tests.get(&url) { if let Some(id) = &item.id { // there is no way to exclude a test step if item.step_id.is_none() && let Some(test) = test_definitions.get(id) { let filter = filters.entry(url.clone()).or_default(); filter.exclude.insert(test.id.clone(), test.clone()); } } else { // the entire test module is excluded queue.remove(&url); } } } queue.retain(|s| !tests.get(s).unwrap().0.is_empty()); (queue, filters) } fn failure_to_test_message(failure: &TestFailure) -> lsp_custom::TestMessage { let message = lsp::MarkupContent { kind: lsp::MarkupKind::PlainText, value: failure .format(&TestFailureFormatOptions::default()) .to_string(), }; let location = failure.error_location().and_then(|v| { let pos = lsp::Position { line: v.line_number, character: v.column_number, }; // Does not have to match the test URI // since one can write `Deno.test(importedFunction)` let uri = uri_parse_unencoded(&v.file_name).ok()?; Some(lsp::Location { uri, range: lsp::Range::new(pos, pos), }) }); lsp_custom::TestMessage { message, expected_output: None, actual_output: None, location, } } #[derive(Debug, Clone, Default, PartialEq)] struct LspTestFilter { include: Option<HashMap<String, TestDefinition>>, exclude: HashMap<String, TestDefinition>, } impl LspTestFilter { fn as_ids(&self, test_module: &TestModule) -> Vec<String> { let ids: Vec<String> = if let Some(include) = &self.include { include.keys().cloned().collect() } else { test_module .defs .iter() .filter(|(_, d)| d.parent_id.is_none()) .map(|(k, _)| k.clone()) .collect() }; ids .into_iter() .filter(|id| !self.exclude.contains_key(id)) .collect() } } #[derive(Debug, Clone)] pub struct TestRun { id: u32, kind: lsp_custom::TestRunKind, filters: HashMap<ModuleSpecifier, LspTestFilter>, queue: HashSet<ModuleSpecifier>, tests: TestServerTests, token: CancellationToken, workspace_settings: config::WorkspaceSettings, /// `--env-file` paths inherited from the `test` task in deno.json. Resolved /// once at run construction so `get_args` doesn't depend on the config tree. test_task_env_files: Vec<PathBuf>, } impl TestRun { pub async fn init( params: &lsp_custom::TestRunRequestParams, tests: TestServerTests, workspace_settings: config::WorkspaceSettings, config_tree: &config::ConfigTree, ) -> Self { let (queue, filters) = { let tests = tests.lock().await; as_queue_and_filters(params, &tests) }; let test_task_env_files = collect_test_task_env_files(&queue, config_tree); Self { id: params.id, kind: params.kind.clone(), filters, queue, tests, token: CancellationToken::new(), workspace_settings, test_task_env_files, } } /// 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 async fn as_enqueued(&self) -> Vec<lsp_custom::EnqueuedTestModule> { let tests = self.tests.lock().await; self .queue .iter() .filter_map(|s| { let ids = if let Some((test_module, _)) = tests.get(s) { if let Some(filter) = self.filters.get(s) { filter.as_ids(test_module) } else { LspTestFilter::default().as_ids(test_module) } } else { Vec::new() }; Some(lsp_custom::EnqueuedTestModule { text_document: lsp::TextDocumentIdentifier { uri: url_to_uri(s).ok()?, }, 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 = Arc::new(flags_from_vec( args.into_iter().map(|s| From::from(s.as_ref())).collect(), )?); // The CLI loads `--env-file` paths in `cli::lib::main`, before the // subcommand factory runs. The LSP test runner bypasses that, so apply // them here against the process environment before constructing the // worker factory. Without this, tests that read env vars at module load // wouldn't see values from a deno.json `test` task's `--env-file`. // // We use the stateful `WatchEnvTracker` so that re-runs after edits to // the env file or task definition refresh values rather than seeing the // first-run snapshot for the lifetime of the LSP. Caveats that callers // should be aware of: // * All test runs share the long-lived LSP's single process env. When // two workspace scopes set the same key to different values, the // first file wins and the other scope silently runs with the wrong // value until the LSP restarts. // * Variables that were present in the process environment *before* // any env file was loaded keep precedence over env files, matching // the CLI's `load_env_variables_from_env_files`. // * Concurrent `deno/testRun` requests racing on the shared global env // can interleave; this is the same hazard the CLI has when running // multiple `deno test` invocations in one process. if let Some(env_files) = &flags.env_file { let cwd = resolve_cwd_or_fallback(flags.initial_cwd.as_deref()); WatchEnvTracker::snapshot().load_env_variables_from_env_files( &cwd, env_files, flags.log_level, ); } let factory = CliFactory::from_flags(flags); let cli_options = factory.cli_options()?; let permission_desc_parser = factory.permission_desc_parser()?; let main_graph_container = factory.main_module_graph_container().await?; main_graph_container .check_specifiers( &self.queue.iter().cloned().collect::<Vec<_>>(), Default::default(), ) .await?; let (concurrent_jobs, fail_fast) = if let DenoSubcommand::Test(test_flags) = cli_options.sub_command() { ( parallelism_count(test_flags.parallel).into(), test_flags.fail_fast, ) } else { unreachable!("Should always be Test subcommand."); }; // TODO(mmastrac): Temporarily limit concurrency in windows testing to avoid named pipe issue: // *** Unexpected server pipe failure '"\\\\.\\pipe\\deno_pipe_e30f45c9df61b1e4.1198.222\\0"': 3 // This is likely because we're hitting some sort of invisible resource limit // This limit is both in cli/lsp/testing/execution.rs and cli/tools/test/mod.rs #[cfg(windows)] let concurrent_jobs = std::cmp::min(concurrent_jobs, 4); let (test_event_sender_factory, mut receiver) = create_test_event_channel(); let fail_fast_tracker = FailFastTracker::new(fail_fast); let mut queue = self.queue.iter().collect::<Vec<&ModuleSpecifier>>(); queue.sort(); let tests: Arc<RwLock<IndexMap<usize, test::TestDescription>>> = Arc::new(RwLock::new(IndexMap::new())); let mut test_steps = IndexMap::new(); let worker_factory = Arc::new(factory.create_cli_main_worker_factory().await?); let join_handles = queue.into_iter().map(move |specifier| { let specifier = specifier.clone(); let specifier_dir = cli_options.workspace().resolve_member_dir(&specifier); let worker_factory = worker_factory.clone(); let cli_options = cli_options.clone(); let permission_desc_parser = permission_desc_parser.clone(); let worker_sender = test_event_sender_factory.worker(); let fail_fast_tracker = fail_fast_tracker.clone(); let lsp_filter = self.filters.get(&specifier); let filter = test::TestFilter { substring: None, regex: None, include: lsp_filter.and_then(|f| { f.include .as_ref() .map(|i| i.values().map(|t| t.name.clone()).collect()) }), exclude: lsp_filter .map(|f| f.exclude.values().map(|t| t.name.clone()).collect()) .unwrap_or_default(), }; let token = self.token.clone(); spawn_blocking(move || { // Various test files should not share the same permissions in terms of // `PermissionsContainer` - otherwise granting/revoking permissions in one // file would have impact on other files, which is undesirable. let permissions = cli_options.permissions_options_for_dir(&specifier_dir)?; let permissions_container = PermissionsContainer::new( permission_desc_parser.clone(), Permissions::from_options( permission_desc_parser.as_ref(), &permissions, )?, ); if fail_fast_tracker.should_stop() { return Ok(()); } if token.is_cancelled() { Ok(()) } else { // All JsErrors are handled by test_specifier and piped into the test // channel. create_and_run_current_thread(test::test_specifier( worker_factory, permissions_container, specifier, // Executing tests in the LSP currently doesn't support preload option vec![], // Executing tests in the LSP currently doesn't support require option vec![], worker_sender, fail_fast_tracker, test::TestSpecifierOptions { filter, shuffle: None, retry: 0, repeats: 0, trace_leaks: false, // LSP-driven test runs intentionally disable sanitizers — the // LSP UI doesn't surface op/resource leak failures usefully // and they'd generate noise in the test gutter. sanitize_ops: false, sanitize_resources: false, update_snapshots: false, }, )) } }) }); let join_stream = stream::iter(join_handles) .buffer_unordered(concurrent_jobs) .collect::<Vec<Result<Result<(), AnyError>, tokio::task::JoinError>>>(); let mut reporter = Box::new(LspTestReporter::new( self, client.clone(), maybe_root_uri, self.tests.clone(), )); let handler = { spawn(async move { let earlier = Instant::now(); let mut summary = test::TestSummary::new(); let mut tests_with_result = HashSet::new(); let mut used_only = false; while let Some((_, event)) = receiver.recv().await { match event { test::TestEvent::Register(description) => { for (_, description) in description.into_iter() { reporter.report_register(description).await; // TODO(mmastrac): we shouldn't need to clone here - we can re-use the descriptions tests.write().insert(description.id, description.clone()); } } 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(id) => { reporter.report_wait(tests.read().get(&id).unwrap()); } test::TestEvent::Output(output) => { reporter.report_output(&output); } test::TestEvent::Slow(id, elapsed) => { reporter.report_slow(tests.read().get(&id).unwrap(), elapsed); } test::TestEvent::Result(id, result, elapsed) => { if tests_with_result.insert(id) { let description = tests.read().get(&id).unwrap().clone(); 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).into(), error.clone())); } test::TestResult::Cancelled => { summary.failed += 1; } } reporter.report_result(&description, &result, elapsed); } } test::TestEvent::UncaughtError(origin, error) => { reporter.report_uncaught_error(&origin, &error).await; summary.failed += 1; summary.uncaught_errors.push((origin, error)); } test::TestEvent::StepRegister(description) => { reporter.report_step_register(&description).await; test_steps.insert(description.id, description); } test::TestEvent::StepWait(id) => { reporter.report_step_wait(test_steps.get(&id).unwrap()); } test::TestEvent::StepResult(id, result, duration) => { if tests_with_result.insert(id) { match &result { test::TestStepResult::Ok => { summary.passed_steps += 1; } test::TestStepResult::Ignored => { summary.ignored_steps += 1; } test::TestStepResult::Failed(_) => { summary.failed_steps += 1; } } reporter.report_step_result( test_steps.get(&id).unwrap(), &result, duration, ); } } test::TestEvent::Retry(..) | test::TestEvent::Repeat(..) => { // Informational only; the test's terminal result is reported via // `TestEvent::Result`. } test::TestEvent::Completed => { reporter.report_completed(); } // LSP-driven test runs never use `--update-snapshots`. test::TestEvent::SnapshotSummary(_) => {} test::TestEvent::ForceEndReport => {} test::TestEvent::Sigint => {} test::TestEvent::Exit(_) => {} test::TestEvent::IsolateExit(_, _) => {} } } 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<Cow<'_, str>> { let mut args = vec![Cow::Borrowed("deno"), Cow::Borrowed("test")]; args.extend( self .workspace_settings .testing .args .iter() .map(|s| Cow::Borrowed(s.as_str())), ); args.push(Cow::Borrowed("--trace-leaks")); for unstable_feature in self.workspace_settings.unstable.as_deref() { let flag = format!("--unstable-{unstable_feature}"); if !args.contains(&Cow::Borrowed(&flag)) { args.push(Cow::Owned(flag)); } } if let Some(config) = &self.workspace_settings.config && !args.contains(&Cow::Borrowed("--config")) && !args.contains(&Cow::Borrowed("-c")) { args.push(Cow::Borrowed("--config")); args.push(Cow::Borrowed(config.as_str())); } if let Some(import_map) = &self.workspace_settings.import_map && !args.contains(&Cow::Borrowed("--import-map")) { args.push(Cow::Borrowed("--import-map")); args.push(Cow::Borrowed(import_map.as_str())); } if self.kind == lsp_custom::TestRunKind::Debug && !args.contains(&Cow::Borrowed("--inspect")) && !args.contains(&Cow::Borrowed("--inspect-brk")) { args.push(Cow::Borrowed("--inspect")); } // Inherit `--env-file` paths from the `test` task in deno.json when the // user hasn't already supplied one via `deno.testing.args`. Without this, // running tests from VSCode wouldn't see env vars that `deno task test` // loads (see https://github.com/denoland/deno/issues/28797). let has_env_file_arg = args.iter().any(|a| { let a = a.as_ref(); a == "--env-file" || a == "--env" || a.starts_with("--env-file=") || a.starts_with("--env=") }); if !has_env_file_arg { for env_file in &self.test_task_env_files { let Some(env_file) = env_file.to_str() else { lsp_log!( "Skipping non-UTF8 env file path from deno.json `test` task: {}", env_file.display() ); continue; }; args.push(Cow::Owned(format!("--env-file={env_file}"))); } } args } } /// Walks each unique scope reached by the queued specifiers and harvests /// `--env-file` paths from its deno.json `test` task. Each path is resolved /// against the deno.json's directory (matching how `deno task test` reads /// them) so the LSP process's cwd doesn't change the answer. Scopes are /// deduplicated so a workspace with one shared deno.json is processed once. fn collect_test_task_env_files( queue: &HashSet<ModuleSpecifier>, config_tree: &config::ConfigTree, ) -> Vec<PathBuf> { let mut env_files = Vec::new(); let mut seen_scopes: HashSet<Arc<deno_core::url::Url>> = HashSet::new(); let mut seen_env_files: HashSet<PathBuf> = HashSet::new(); for specifier in queue { let Some(data) = config_tree.data_for_specifier(specifier) else { continue; }; if !seen_scopes.insert(data.scope.clone()) { continue; } let Some(config_file) = data.maybe_deno_json() else { continue; }; let Ok(Some(tasks)) = config_file.to_tasks_config() else { continue; }; let Some(task) = tasks.get("test") else { continue; }; let Some(command) = &task.command else { continue; }; let config_dir = config_file .specifier .to_file_path() .ok() .and_then(|p| p.parent().map(std::path::Path::to_path_buf)); for env_file in extract_env_files_from_command(command) { let resolved = match &config_dir { Some(dir) => dir.join(&env_file), None => PathBuf::from(env_file), }; if seen_env_files.insert(resolved.clone()) { env_files.push(resolved); } } } env_files } /// Scans a task command string for `--env-file`/`--env` flags and returns the /// associated file paths. A bare `--env-file` flag (no value) defaults to /// `.env` to match the CLI behavior. In compound task commands, only `deno /// test ...` command segments are scanned so env files belonging to unrelated /// commands are not inherited by the LSP test runner. fn extract_env_files_from_command(command: &str) -> Vec<String> { let Some(tokens) = shlex::split(command) else { return Vec::new(); }; let mut env_files = Vec::new(); let mut index = 0; while index + 1 < tokens.len() { if tokens[index] == "deno" && tokens[index + 1] == "test" { index += 2; while index < tokens.len() && !is_shell_command_separator(&tokens[index]) { let token = &tokens[index]; if let Some(rest) = token.strip_prefix("--env-file=") { env_files.push(rest.to_string()); } else if let Some(rest) = token.strip_prefix("--env=") { env_files.push(rest.to_string()); } else if token == "--env-file" || token == "--env" { env_files.push(".env".to_string()); } index += 1; } } else { index += 1; } } env_files } fn is_shell_command_separator(token: &str) -> bool { matches!(token, "&&" | "||" | ";" | "|") } #[derive(Debug, PartialEq)] enum LspTestDescription { /// `(desc, static_id)` TestDescription(test::TestDescription, String), /// `(desc, static_id)` TestStepDescription(test::TestStepDescription, String), } impl LspTestDescription { fn origin(&self) -> &str { match self { LspTestDescription::TestDescription(d, _) => d.origin.as_str(), LspTestDescription::TestStepDescription(d, _) => d.origin.as_str(), } } fn location(&self) -> &test::TestLocation { match self { LspTestDescription::TestDescription(d, _) => &d.location, LspTestDescription::TestStepDescription(d, _) => &d.location, } } fn parent_id(&self) -> Option<usize> { match self { LspTestDescription::TestDescription(_, _) => None, LspTestDescription::TestStepDescription(d, _) => Some(d.parent_id), } } fn static_id(&self) -> &str { match self { LspTestDescription::TestDescription(_, i) => i, LspTestDescription::TestStepDescription(_, i) => i, } } fn as_test_identifier( &self, tests: &IndexMap<usize, LspTestDescription>, ) -> lsp_custom::TestIdentifier { let mut root_desc = self; while let Some(parent_id) = root_desc.parent_id() { root_desc = tests.get(&parent_id).unwrap(); } let uri = uri_parse_unencoded(&root_desc.location().file_name).unwrap(); let static_id = self.static_id(); let root_static_id = root_desc.static_id(); lsp_custom::TestIdentifier { text_document: lsp::TextDocumentIdentifier { uri }, id: Some(root_static_id.to_string()), step_id: if static_id == root_static_id { None } else { Some(static_id.to_string()) }, } } } struct LspTestReporter { client: Client, id: u32, maybe_root_uri: Option<ModuleSpecifier>, files: TestServerTests, tests: IndexMap<usize, LspTestDescription>, current_test: Option<usize>, /// Counts of dynamic test registrations per `(parent_static_id, name)` for /// the current run, used to assign a `name_index` that matches the static /// collector's numbering when multiple tests share the same name under the /// same parent. See https://github.com/denoland/deno/issues/20371. dynamic_name_indices: HashMap<(Option<String>, String), u32>, } impl LspTestReporter { fn new( run: &TestRun, client: Client, maybe_root_uri: Option<&ModuleSpecifier>, files: TestServerTests, ) -> Self { Self { client, id: run.id, maybe_root_uri: maybe_root_uri.cloned(), files, tests: Default::default(), current_test: Default::default(), dynamic_name_indices: HashMap::new(), } } fn next_dynamic_name_index( &mut self, parent_static_id: Option<&str>, name: &str, ) -> u32 { let key = (parent_static_id.map(str::to_owned), name.to_string()); let entry = self.dynamic_name_indices.entry(key).or_insert(0); let index = *entry; *entry += 1; index } fn progress(&self, message: lsp_custom::TestRunProgressMessage) { self .client .send_test_notification(TestingNotification::Progress( lsp_custom::TestRunProgressParams { id: self.id, message, }, )); } fn report_plan(&mut self, _plan: &test::TestPlan) {} async fn report_register(&mut self, desc: &test::TestDescription) { let name_index = self.next_dynamic_name_index(None, &desc.name); let mut files = self.files.lock().await; let specifier = ModuleSpecifier::parse(&desc.location.file_name).unwrap(); let (test_module, _) = files .entry(specifier.clone()) .or_insert_with(|| (TestModule::new(specifier), "1".to_string())); let Ok(uri) = url_to_uri(&test_module.specifier) else { return; }; let (static_id, is_new) = test_module.register_dynamic(desc, name_index); self.tests.insert( desc.id, LspTestDescription::TestDescription(desc.clone(), static_id.clone()), ); if is_new { self .client .send_test_notification(TestingNotification::Module( lsp_custom::TestModuleNotificationParams { text_document: lsp::TextDocumentIdentifier { uri }, kind: lsp_custom::TestModuleNotificationKind::Insert, label: test_module.label(self.maybe_root_uri.as_ref()), tests: vec![test_module.get_test_data(&static_id)], }, )); } } fn report_wait(&mut self, desc: &test::TestDescription) { self.current_test = Some(desc.id); let desc = self.tests.get(&desc.id).unwrap(); let test = desc.as_test_identifier(&self.tests); self.progress(lsp_custom::TestRunProgressMessage::Started { test }); } fn report_slow(&mut self, _desc: &test::TestDescription, _elapsed: Duration) { } fn report_output(&mut self, output: &[u8]) { let test = self .current_test .as_ref() .map(|id| self.tests.get(id).unwrap().as_test_identifier(&self.tests)); let value = String::from_utf8_lossy(output).replace('\n', "\r\n"); self.progress(lsp_custom::TestRunProgressMessage::Output { value, test, // TODO(@kitsonk) test output should include a location location: None, }) } fn report_result( &mut self, desc: &test::TestDescription, result: &test::TestResult, elapsed: Duration, ) { self.current_test = None; let elapsed = elapsed.as_millis() as u32; match result { test::TestResult::Ok => { let desc = self.tests.get(&desc.id).unwrap(); self.progress(lsp_custom::TestRunProgressMessage::Passed { test: desc.as_test_identifier(&self.tests), duration: Some(elapsed), }) } test::TestResult::Ignored => { let desc = self.tests.get(&desc.id).unwrap(); self.progress(lsp_custom::TestRunProgressMessage::Skipped { test: desc.as_test_identifier(&self.tests), }) } test::TestResult::Failed(failure) => { let desc = self.tests.get(&desc.id).unwrap(); self.progress(lsp_custom::TestRunProgressMessage::Failed { test: desc.as_test_identifier(&self.tests), messages: vec![failure_to_test_message(failure)], duration: Some(elapsed), }) } test::TestResult::Cancelled => { let desc = self.tests.get(&desc.id).unwrap(); self.progress(lsp_custom::TestRunProgressMessage::Failed { test: desc.as_test_identifier(&self.tests), messages: vec![], duration: Some(elapsed), }) } } } async fn report_uncaught_error(&mut self, origin: &str, js_error: &JsError) { self.current_test = None; let err_string = format!( "Uncaught error from {}: {}\nThis error was not caught from a test and caused the test runner to fail on the referenced module.\nIt most likely originated from a dangling promise, event/timeout handler or top-level code.", origin, test::fmt::format_test_error( js_error, &TestFailureFormatOptions::default() ) ); let messages = vec![lsp_custom::TestMessage { message: lsp::MarkupContent { kind: lsp::MarkupKind::PlainText, value: err_string, }, expected_output: None, actual_output: None, location: None, }]; let mut reported = false; for desc in self.tests.values().filter(|d| d.origin() == origin) { self.progress(lsp_custom::TestRunProgressMessage::Failed { test: desc.as_test_identifier(&self.tests), messages: messages.clone(), duration: None, }); reported = true; } if reported { return; } // No individual test was registered for this origin at runtime. This happens // when the module throws while evaluating, before (or instead of) any test // runs — for example `Deno.test("")` throws "The test name can't be empty". // Surface the failure against the module's statically-collected tests (which // were already enqueued on the client) so the run still reports the error // instead of silently completing. See // https://github.com/denoland/deno/issues/17119. let Ok(specifier) = ModuleSpecifier::parse(origin) else { return; }; let Ok(uri) = url_to_uri(&specifier) else { return; }; { let files = self.files.lock().await; if let Some((test_module, _)) = files.get(&specifier) { for id in test_module .defs .iter() .filter(|(_, d)| d.parent_id.is_none()) .map(|(id, _)| id.clone()) { self.progress(lsp_custom::TestRunProgressMessage::Failed { test: lsp_custom::TestIdentifier { text_document: lsp::TextDocumentIdentifier { uri: uri.clone() }, id: Some(id), step_id: None, }, messages: messages.clone(), duration: None, }); reported = true; } } } // As a last resort (e.g. the module has no statically-collected tests), // report against the module itself so the error is never dropped. if !reported { self.progress(lsp_custom::TestRunProgressMessage::Failed { test: lsp_custom::TestIdentifier { text_document: lsp::TextDocumentIdentifier { uri }, id: None, step_id: None, }, messages, duration: None, }); } } async fn report_step_register(&mut self, desc: &test::TestStepDescription) { let Some((parent_static_id, file_name)) = self.tests.get(&desc.parent_id).and_then(|parent_desc| { let parent_static_id = parent_desc.static_id().to_string(); let mut root_desc = parent_desc; while let Some(parent_id) = root_desc.parent_id() { root_desc = self.tests.get(&parent_id)?; } Some((parent_static_id, root_desc.location().file_name.clone())) }) else { return; }; let name_index = self.next_dynamic_name_index(Some(&parent_static_id), &desc.name); let mut files = self.files.lock().await; let specifier = ModuleSpecifier::parse(&file_name).unwrap(); let (test_module, _) = files .entry(specifier.clone()) .or_insert_with(|| (TestModule::new(specifier), "1".to_string())); let Ok(uri) = url_to_uri(&test_module.specifier) else { return; }; let (static_id, is_new) = test_module.register_step_dynamic(desc, &parent_static_id, name_index); self.tests.insert( desc.id, LspTestDescription::TestStepDescription(desc.clone(), static_id.clone()), ); if is_new { self .client .send_test_notification(TestingNotification::Module( lsp_custom::TestModuleNotificationParams { text_document: lsp::TextDocumentIdentifier { uri }, kind: lsp_custom::TestModuleNotificationKind::Insert, label: test_module.label(self.maybe_root_uri.as_ref()), tests: vec![test_module.get_test_data(&static_id)], }, )); } } fn report_step_wait(&mut self, desc: &test::TestStepDescription) { if self.current_test == Some(desc.parent_id) { self.current_test = Some(desc.id); } let desc = self.tests.get(&desc.id).unwrap(); let test = desc.as_test_identifier(&self.tests); self.progress(lsp_custom::TestRunProgressMessage::Started { test }); } fn report_step_result( &mut self, desc: &test::TestStepDescription, result: &test::TestStepResult, elapsed: Duration, ) { let elapsed = elapsed.as_millis() as u32; if self.current_test == Some(desc.id) { self.current_test = Some(desc.parent_id); } let desc = self.tests.get(&desc.id).unwrap(); match result { test::TestStepResult::Ok => { self.progress(lsp_custom::TestRunProgressMessage::Passed { test: desc.as_test_identifier(&self.tests), duration: Some(elapsed), }) } test::TestStepResult::Ignored => { self.progress(lsp_custom::TestRunProgressMessage::Skipped { test: desc.as_test_identifier(&self.tests), }) } test::TestStepResult::Failed(failure) => { self.progress(lsp_custom::TestRunProgressMessage::Failed { test: desc.as_test_identifier(&self.tests), messages: vec![failure_to_test_message(failure)], duration: Some(elapsed), }) } } } fn report_completed(&mut self) { // there is nothing to do on report_completed } fn report_summary( &mut self, _summary: &test::TestSummary, _elapsed: &Duration, ) { // there is nothing to do on report_summary } } #[cfg(test)] mod tests { use deno_core::serde_json::json; use super::*; use crate::lsp::testing::collectors::tests::new_range; #[test] fn test_as_queue_and_filters() { let specifier = ModuleSpecifier::parse("file:///a/file.ts").unwrap(); // Regression test for https://github.com/denoland/vscode_deno/issues/890. let non_test_specifier = ModuleSpecifier::parse("file:///a/no_tests.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: url_to_uri(&specifier).unwrap(), }, id: None, step_id: None, }, lsp_custom::TestIdentifier { text_document: lsp::TextDocumentIdentifier { uri: url_to_uri(&non_test_specifier).unwrap(), }, id: None, step_id: None, }, ]), exclude: vec![lsp_custom::TestIdentifier { text_document: lsp::TextDocumentIdentifier { uri: url_to_uri(&specifier).unwrap(), }, id: Some( "69d9fe87f64f5b66cb8b631d4fd2064e8224b8715a049be54276c42189ff8f9f" .to_string(), ), step_id: None, }], }; let mut tests = HashMap::new(); let test_def_a = TestDefinition { id: "0b7c6bf3cd617018d33a1bf982a08fe088c5bb54fcd5eb9e802e7c137ec1af94" .to_string(), name: "test a".to_string(), name_index: 0, range: Some(new_range(1, 5, 1, 9)), is_dynamic: false, parent_id: None, step_ids: Default::default(), }; let test_def_b = TestDefinition { id: "69d9fe87f64f5b66cb8b631d4fd2064e8224b8715a049be54276c42189ff8f9f" .to_string(), name: "test b".to_string(), name_index: 0, range: Some(new_range(2, 5, 2, 9)), is_dynamic: false, parent_id: None, step_ids: Default::default(), }; let test_module = TestModule { specifier: specifier.clone(), defs: vec![ (test_def_a.id.clone(), test_def_a.clone()), (test_def_b.id.clone(), test_def_b.clone()), ] .into_iter() .collect(), }; tests.insert(specifier.clone(), (test_module.clone(), "1".to_string())); tests.insert( non_test_specifier.clone(), (TestModule::new(non_test_specifier), "1".to_string()), ); 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, &LspTestFilter { include: None, exclude, } ); assert_eq!( filter.as_ids(&test_module), vec![ "0b7c6bf3cd617018d33a1bf982a08fe088c5bb54fcd5eb9e802e7c137ec1af94" .to_string() ] ); } #[test] fn test_extract_env_files_from_command() { // Plain `deno test --env-file=.env.test -A` (the case from // https://github.com/denoland/deno/issues/28797). assert_eq!( extract_env_files_from_command("deno test --env-file=.env.test -A"), vec![".env.test".to_string()], ); // Bare `--env-file` defaults to `.env` (matches CLI default). assert_eq!( extract_env_files_from_command("deno test --env-file -A"), vec![".env".to_string()], ); // `--env` alias. assert_eq!( extract_env_files_from_command("deno test --env=.env.local"), vec![".env.local".to_string()], ); // Multiple env files, possibly amid other args. assert_eq!( extract_env_files_from_command( "deno test --env-file=.env --env-file=.env.test -A" ), vec![".env".to_string(), ".env.test".to_string()], ); // Compound shell commands — we still extract the env file the user // intended for the test run. assert_eq!( extract_env_files_from_command( "deno fmt && deno test --env-file=.env.test" ), vec![".env.test".to_string()], ); // Env files belonging to other commands in a compound task are ignored. assert_eq!( extract_env_files_from_command( "deno run --env-file=.env.prod setup.ts && deno test --env-file=.env.test" ), vec![".env.test".to_string()], ); assert!( extract_env_files_from_command( "deno run --env-file=.env.prod setup.ts && deno test" ) .is_empty() ); // Quoted path containing whitespace is preserved by the shlex split. assert_eq!( extract_env_files_from_command( "deno test --env-file=\"my env/.env.test\"" ), vec!["my env/.env.test".to_string()], ); // No env-file flag. assert!(extract_env_files_from_command("deno test -A").is_empty()); // Malformed command (unterminated quote) is treated as no env files. assert!( extract_env_files_from_command("deno test --env-file=\"foo").is_empty() ); } }