1use anyhow::{Context as _, Result};
2use async_trait::async_trait;
3use chrono::{DateTime, Local};
4use collections::HashMap;
5use futures::future::join_all;
6use gpui::{App, AppContext, AsyncApp, Entity, Task};
7use itertools::Itertools as _;
8use language::{
9 Buffer, ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore,
10 LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain,
11};
12use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
13use node_runtime::{NodeRuntime, VersionStrategy};
14use project::{Fs, lsp_store::language_server_settings};
15use semver::Version;
16use serde_json::{Value, json};
17use smol::lock::RwLock;
18use std::{
19 borrow::Cow,
20 ffi::OsString,
21 path::{Path, PathBuf},
22 sync::{Arc, LazyLock},
23};
24use task::{TaskTemplate, TaskTemplates, VariableName};
25use util::rel_path::RelPath;
26use util::{ResultExt, maybe};
27
28use crate::{PackageJson, PackageJsonData};
29
30pub(crate) struct TypeScriptContextProvider {
31 fs: Arc<dyn Fs>,
32 last_package_json: PackageJsonContents,
33}
34
35const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
36 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
37
38const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
39 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
40
41const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
42 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
43
44const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
45 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
46
47const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
48 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
49
50const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
51 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
52
53const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
54 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
55
56const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
57 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
58
59const TYPESCRIPT_BUN_TEST_NAME_VARIABLE: VariableName =
60 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_TEST_NAME"));
61
62const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
63 VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
64
65#[derive(Clone, Debug, Default)]
66struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
67
68impl PackageJsonData {
69 fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
70 if self.jest_package_path.is_some() {
71 task_templates.0.push(TaskTemplate {
72 label: "jest file test".to_owned(),
73 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
74 args: vec![
75 "exec".to_owned(),
76 "--".to_owned(),
77 "jest".to_owned(),
78 "--runInBand".to_owned(),
79 VariableName::File.template_value(),
80 ],
81 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
82 ..TaskTemplate::default()
83 });
84 task_templates.0.push(TaskTemplate {
85 label: format!("jest test {}", VariableName::Symbol.template_value()),
86 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
87 args: vec![
88 "exec".to_owned(),
89 "--".to_owned(),
90 "jest".to_owned(),
91 "--runInBand".to_owned(),
92 "--testNamePattern".to_owned(),
93 format!(
94 "\"{}\"",
95 TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
96 ),
97 VariableName::File.template_value(),
98 ],
99 tags: vec![
100 "ts-test".to_owned(),
101 "js-test".to_owned(),
102 "tsx-test".to_owned(),
103 ],
104 cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
105 ..TaskTemplate::default()
106 });
107 }
108
109 if self.vitest_package_path.is_some() {
110 task_templates.0.push(TaskTemplate {
111 label: format!("{} file test", "vitest".to_owned()),
112 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
113 args: vec![
114 "exec".to_owned(),
115 "--".to_owned(),
116 "vitest".to_owned(),
117 "run".to_owned(),
118 "--no-file-parallelism".to_owned(),
119 VariableName::File.template_value(),
120 ],
121 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
122 ..TaskTemplate::default()
123 });
124 task_templates.0.push(TaskTemplate {
125 label: format!(
126 "{} test {}",
127 "vitest".to_owned(),
128 VariableName::Symbol.template_value(),
129 ),
130 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
131 args: vec![
132 "exec".to_owned(),
133 "--".to_owned(),
134 "vitest".to_owned(),
135 "run".to_owned(),
136 "--no-file-parallelism".to_owned(),
137 "--testNamePattern".to_owned(),
138 format!(
139 "\"{}\"",
140 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
141 ),
142 VariableName::File.template_value(),
143 ],
144 tags: vec![
145 "ts-test".to_owned(),
146 "js-test".to_owned(),
147 "tsx-test".to_owned(),
148 ],
149 cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
150 ..TaskTemplate::default()
151 });
152 }
153
154 if self.mocha_package_path.is_some() {
155 task_templates.0.push(TaskTemplate {
156 label: format!("{} file test", "mocha".to_owned()),
157 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
158 args: vec![
159 "exec".to_owned(),
160 "--".to_owned(),
161 "mocha".to_owned(),
162 VariableName::File.template_value(),
163 ],
164 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
165 ..TaskTemplate::default()
166 });
167 task_templates.0.push(TaskTemplate {
168 label: format!(
169 "{} test {}",
170 "mocha".to_owned(),
171 VariableName::Symbol.template_value(),
172 ),
173 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
174 args: vec![
175 "exec".to_owned(),
176 "--".to_owned(),
177 "mocha".to_owned(),
178 "--grep".to_owned(),
179 format!("\"{}\"", VariableName::Symbol.template_value()),
180 VariableName::File.template_value(),
181 ],
182 tags: vec![
183 "ts-test".to_owned(),
184 "js-test".to_owned(),
185 "tsx-test".to_owned(),
186 ],
187 cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
188 ..TaskTemplate::default()
189 });
190 }
191
192 if self.jasmine_package_path.is_some() {
193 task_templates.0.push(TaskTemplate {
194 label: format!("{} file test", "jasmine".to_owned()),
195 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
196 args: vec![
197 "exec".to_owned(),
198 "--".to_owned(),
199 "jasmine".to_owned(),
200 VariableName::File.template_value(),
201 ],
202 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
203 ..TaskTemplate::default()
204 });
205 task_templates.0.push(TaskTemplate {
206 label: format!(
207 "{} test {}",
208 "jasmine".to_owned(),
209 VariableName::Symbol.template_value(),
210 ),
211 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
212 args: vec![
213 "exec".to_owned(),
214 "--".to_owned(),
215 "jasmine".to_owned(),
216 format!("--filter={}", VariableName::Symbol.template_value()),
217 VariableName::File.template_value(),
218 ],
219 tags: vec![
220 "ts-test".to_owned(),
221 "js-test".to_owned(),
222 "tsx-test".to_owned(),
223 ],
224 cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
225 ..TaskTemplate::default()
226 });
227 }
228
229 if self.bun_package_path.is_some() {
230 task_templates.0.push(TaskTemplate {
231 label: format!("{} file test", "bun test".to_owned()),
232 command: "bun".to_owned(),
233 args: vec!["test".to_owned(), VariableName::File.template_value()],
234 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
235 ..TaskTemplate::default()
236 });
237 task_templates.0.push(TaskTemplate {
238 label: format!("bun test {}", VariableName::Symbol.template_value(),),
239 command: "bun".to_owned(),
240 args: vec![
241 "test".to_owned(),
242 "--test-name-pattern".to_owned(),
243 format!("\"{}\"", TYPESCRIPT_BUN_TEST_NAME_VARIABLE.template_value()),
244 VariableName::File.template_value(),
245 ],
246 tags: vec![
247 "ts-test".to_owned(),
248 "js-test".to_owned(),
249 "tsx-test".to_owned(),
250 ],
251 cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
252 ..TaskTemplate::default()
253 });
254 }
255
256 if self.node_package_path.is_some() {
257 task_templates.0.push(TaskTemplate {
258 label: format!("{} file test", "node test".to_owned()),
259 command: "node".to_owned(),
260 args: vec!["--test".to_owned(), VariableName::File.template_value()],
261 tags: vec![
262 "ts-test".to_owned(),
263 "js-test".to_owned(),
264 "tsx-test".to_owned(),
265 ],
266 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
267 ..TaskTemplate::default()
268 });
269 task_templates.0.push(TaskTemplate {
270 label: format!("node test {}", VariableName::Symbol.template_value()),
271 command: "node".to_owned(),
272 args: vec![
273 "--test".to_owned(),
274 "--test-name-pattern".to_owned(),
275 format!("\"{}\"", VariableName::Symbol.template_value()),
276 VariableName::File.template_value(),
277 ],
278 tags: vec![
279 "ts-test".to_owned(),
280 "js-test".to_owned(),
281 "tsx-test".to_owned(),
282 ],
283 cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
284 ..TaskTemplate::default()
285 });
286 }
287
288 let script_name_counts: HashMap<_, usize> =
289 self.scripts
290 .iter()
291 .fold(HashMap::default(), |mut acc, (_, script)| {
292 *acc.entry(script).or_default() += 1;
293 acc
294 });
295 for (path, script) in &self.scripts {
296 let label = if script_name_counts.get(script).copied().unwrap_or_default() > 1
297 && let Some(parent) = path.parent().and_then(|parent| parent.file_name())
298 {
299 let parent = parent.to_string_lossy();
300 format!("{parent}/package.json > {script}")
301 } else {
302 format!("package.json > {script}")
303 };
304 task_templates.0.push(TaskTemplate {
305 label,
306 command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
307 args: vec!["run".to_owned(), script.to_owned()],
308 tags: vec!["package-script".into()],
309 cwd: Some(
310 path.parent()
311 .unwrap_or(Path::new("/"))
312 .to_string_lossy()
313 .to_string(),
314 ),
315 ..TaskTemplate::default()
316 });
317 }
318 }
319}
320
321impl TypeScriptContextProvider {
322 pub fn new(fs: Arc<dyn Fs>) -> Self {
323 Self {
324 fs,
325 last_package_json: PackageJsonContents::default(),
326 }
327 }
328
329 fn combined_package_json_data(
330 &self,
331 fs: Arc<dyn Fs>,
332 worktree_root: &Path,
333 file_relative_path: &RelPath,
334 cx: &App,
335 ) -> Task<anyhow::Result<PackageJsonData>> {
336 let new_json_data = file_relative_path
337 .ancestors()
338 .map(|path| worktree_root.join(path.as_std_path()))
339 .map(|parent_path| {
340 self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
341 })
342 .collect::<Vec<_>>();
343
344 cx.background_spawn(async move {
345 let mut package_json_data = PackageJsonData::default();
346 for new_data in join_all(new_json_data).await.into_iter().flatten() {
347 package_json_data.merge(new_data);
348 }
349 Ok(package_json_data)
350 })
351 }
352
353 fn package_json_data(
354 &self,
355 directory_path: &Path,
356 existing_package_json: PackageJsonContents,
357 fs: Arc<dyn Fs>,
358 cx: &App,
359 ) -> Task<anyhow::Result<PackageJsonData>> {
360 let package_json_path = directory_path.join("package.json");
361 let metadata_check_fs = fs.clone();
362 cx.background_spawn(async move {
363 let metadata = metadata_check_fs
364 .metadata(&package_json_path)
365 .await
366 .with_context(|| format!("getting metadata for {package_json_path:?}"))?
367 .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
368 let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
369 let existing_data = {
370 let contents = existing_package_json.0.read().await;
371 contents
372 .get(&package_json_path)
373 .filter(|package_json| package_json.mtime == mtime)
374 .map(|package_json| package_json.data.clone())
375 };
376 match existing_data {
377 Some(existing_data) => Ok(existing_data),
378 None => {
379 let package_json_string =
380 fs.load(&package_json_path).await.with_context(|| {
381 format!("loading package.json from {package_json_path:?}")
382 })?;
383 let package_json: HashMap<String, serde_json_lenient::Value> =
384 serde_json_lenient::from_str(&package_json_string).with_context(|| {
385 format!("parsing package.json from {package_json_path:?}")
386 })?;
387 let new_data =
388 PackageJsonData::new(package_json_path.as_path().into(), package_json);
389 {
390 let mut contents = existing_package_json.0.write().await;
391 contents.insert(
392 package_json_path,
393 PackageJson {
394 mtime,
395 data: new_data.clone(),
396 },
397 );
398 }
399 Ok(new_data)
400 }
401 }
402 })
403 }
404}
405
406async fn detect_package_manager(
407 worktree_root: PathBuf,
408 fs: Arc<dyn Fs>,
409 package_json_data: Option<PackageJsonData>,
410) -> &'static str {
411 if let Some(package_json_data) = package_json_data
412 && let Some(package_manager) = package_json_data.package_manager
413 {
414 return package_manager;
415 }
416 if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
417 return "pnpm";
418 }
419 if fs.is_file(&worktree_root.join("yarn.lock")).await {
420 return "yarn";
421 }
422 "npm"
423}
424
425impl ContextProvider for TypeScriptContextProvider {
426 fn associated_tasks(
427 &self,
428 buffer: Option<Entity<Buffer>>,
429 cx: &App,
430 ) -> Task<Option<TaskTemplates>> {
431 let file = buffer.and_then(|buffer| buffer.read(cx).file());
432 let Some(file) = project::File::from_dyn(file).cloned() else {
433 return Task::ready(None);
434 };
435 let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
436 return Task::ready(None);
437 };
438 let file_relative_path = file.path().clone();
439 let package_json_data = self.combined_package_json_data(
440 self.fs.clone(),
441 &worktree_root,
442 &file_relative_path,
443 cx,
444 );
445
446 cx.background_spawn(async move {
447 let mut task_templates = TaskTemplates(Vec::new());
448 task_templates.0.push(TaskTemplate {
449 label: format!(
450 "execute selection {}",
451 VariableName::SelectedText.template_value()
452 ),
453 command: "node".to_owned(),
454 args: vec![
455 "-e".to_owned(),
456 format!("\"{}\"", VariableName::SelectedText.template_value()),
457 ],
458 ..TaskTemplate::default()
459 });
460
461 match package_json_data.await {
462 Ok(package_json) => {
463 package_json.fill_task_templates(&mut task_templates);
464 }
465 Err(e) => {
466 log::error!(
467 "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
468 );
469 }
470 }
471
472 Some(task_templates)
473 })
474 }
475
476 fn build_context(
477 &self,
478 current_vars: &task::TaskVariables,
479 location: ContextLocation<'_>,
480 _project_env: Option<HashMap<String, String>>,
481 _toolchains: Arc<dyn LanguageToolchainStore>,
482 cx: &mut App,
483 ) -> Task<Result<task::TaskVariables>> {
484 let mut vars = task::TaskVariables::default();
485
486 if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
487 vars.insert(
488 TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
489 replace_test_name_parameters(symbol),
490 );
491 vars.insert(
492 TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
493 replace_test_name_parameters(symbol),
494 );
495 vars.insert(
496 TYPESCRIPT_BUN_TEST_NAME_VARIABLE,
497 replace_test_name_parameters(symbol),
498 );
499 }
500 let file_path = location
501 .file_location
502 .buffer
503 .read(cx)
504 .file()
505 .map(|file| file.path());
506
507 let args = location.worktree_root.zip(location.fs).zip(file_path).map(
508 |((worktree_root, fs), file_path)| {
509 (
510 self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
511 worktree_root,
512 fs,
513 )
514 },
515 );
516 cx.background_spawn(async move {
517 if let Some((task, worktree_root, fs)) = args {
518 let package_json_data = task.await.log_err();
519 vars.insert(
520 TYPESCRIPT_RUNNER_VARIABLE,
521 detect_package_manager(worktree_root, fs, package_json_data.clone())
522 .await
523 .to_owned(),
524 );
525
526 if let Some(package_json_data) = package_json_data {
527 if let Some(path) = package_json_data.jest_package_path {
528 vars.insert(
529 TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
530 path.parent()
531 .unwrap_or(Path::new(""))
532 .to_string_lossy()
533 .to_string(),
534 );
535 }
536
537 if let Some(path) = package_json_data.mocha_package_path {
538 vars.insert(
539 TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
540 path.parent()
541 .unwrap_or(Path::new(""))
542 .to_string_lossy()
543 .to_string(),
544 );
545 }
546
547 if let Some(path) = package_json_data.vitest_package_path {
548 vars.insert(
549 TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
550 path.parent()
551 .unwrap_or(Path::new(""))
552 .to_string_lossy()
553 .to_string(),
554 );
555 }
556
557 if let Some(path) = package_json_data.jasmine_package_path {
558 vars.insert(
559 TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
560 path.parent()
561 .unwrap_or(Path::new(""))
562 .to_string_lossy()
563 .to_string(),
564 );
565 }
566
567 if let Some(path) = package_json_data.bun_package_path {
568 vars.insert(
569 TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
570 path.parent()
571 .unwrap_or(Path::new(""))
572 .to_string_lossy()
573 .to_string(),
574 );
575 }
576
577 if let Some(path) = package_json_data.node_package_path {
578 vars.insert(
579 TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
580 path.parent()
581 .unwrap_or(Path::new(""))
582 .to_string_lossy()
583 .to_string(),
584 );
585 }
586 }
587 }
588 Ok(vars)
589 })
590 }
591}
592
593fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
594 vec![server_path.into(), "--stdio".into()]
595}
596
597fn replace_test_name_parameters(test_name: &str) -> String {
598 static PATTERN: LazyLock<regex::Regex> =
599 LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
600 PATTERN.split(test_name).map(regex::escape).join("(.+?)")
601}
602
603pub struct TypeScriptLspAdapter {
604 fs: Arc<dyn Fs>,
605 node: NodeRuntime,
606}
607
608impl TypeScriptLspAdapter {
609 const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js";
610 const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs";
611
612 const PACKAGE_NAME: &str = "typescript";
613 const SERVER_PACKAGE_NAME: &str = "typescript-language-server";
614
615 const SERVER_NAME: LanguageServerName =
616 LanguageServerName::new_static(Self::SERVER_PACKAGE_NAME);
617
618 pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
619 TypeScriptLspAdapter { fs, node }
620 }
621
622 async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
623 let is_yarn = adapter
624 .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
625 .await
626 .is_ok();
627
628 let tsdk_path = if is_yarn {
629 ".yarn/sdks/typescript/lib"
630 } else {
631 "node_modules/typescript/lib"
632 };
633
634 if self
635 .fs
636 .is_dir(&adapter.worktree_root_path().join(tsdk_path))
637 .await
638 {
639 Some(tsdk_path)
640 } else {
641 None
642 }
643 }
644}
645
646pub struct TypeScriptVersions {
647 typescript_version: Version,
648 server_version: Version,
649}
650
651impl LspInstaller for TypeScriptLspAdapter {
652 type BinaryVersion = TypeScriptVersions;
653
654 async fn fetch_latest_server_version(
655 &self,
656 _: &dyn LspAdapterDelegate,
657 _: bool,
658 _: &mut AsyncApp,
659 ) -> Result<Self::BinaryVersion> {
660 Ok(TypeScriptVersions {
661 typescript_version: self
662 .node
663 .npm_package_latest_version(Self::PACKAGE_NAME)
664 .await?,
665 server_version: self
666 .node
667 .npm_package_latest_version(Self::SERVER_PACKAGE_NAME)
668 .await?,
669 })
670 }
671
672 async fn check_if_version_installed(
673 &self,
674 version: &Self::BinaryVersion,
675 container_dir: &PathBuf,
676 _: &dyn LspAdapterDelegate,
677 ) -> Option<LanguageServerBinary> {
678 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
679
680 if self
681 .node
682 .should_install_npm_package(
683 Self::PACKAGE_NAME,
684 &server_path,
685 container_dir,
686 VersionStrategy::Latest(&version.typescript_version),
687 )
688 .await
689 {
690 return None;
691 }
692
693 if self
694 .node
695 .should_install_npm_package(
696 Self::SERVER_PACKAGE_NAME,
697 &server_path,
698 container_dir,
699 VersionStrategy::Latest(&version.server_version),
700 )
701 .await
702 {
703 return None;
704 }
705
706 Some(LanguageServerBinary {
707 path: self.node.binary_path().await.ok()?,
708 env: None,
709 arguments: typescript_server_binary_arguments(&server_path),
710 })
711 }
712
713 async fn fetch_server_binary(
714 &self,
715 latest_version: Self::BinaryVersion,
716 container_dir: PathBuf,
717 _: &dyn LspAdapterDelegate,
718 ) -> Result<LanguageServerBinary> {
719 let server_path = container_dir.join(Self::NEW_SERVER_PATH);
720
721 self.node
722 .npm_install_packages(
723 &container_dir,
724 &[
725 (
726 Self::PACKAGE_NAME,
727 &latest_version.typescript_version.to_string(),
728 ),
729 (
730 Self::SERVER_PACKAGE_NAME,
731 &latest_version.server_version.to_string(),
732 ),
733 ],
734 )
735 .await?;
736
737 Ok(LanguageServerBinary {
738 path: self.node.binary_path().await?,
739 env: None,
740 arguments: typescript_server_binary_arguments(&server_path),
741 })
742 }
743
744 async fn cached_server_binary(
745 &self,
746 container_dir: PathBuf,
747 _: &dyn LspAdapterDelegate,
748 ) -> Option<LanguageServerBinary> {
749 get_cached_ts_server_binary(container_dir, &self.node).await
750 }
751}
752
753#[async_trait(?Send)]
754impl LspAdapter for TypeScriptLspAdapter {
755 fn name(&self) -> LanguageServerName {
756 Self::SERVER_NAME
757 }
758
759 fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
760 Some(vec![
761 CodeActionKind::QUICKFIX,
762 CodeActionKind::REFACTOR,
763 CodeActionKind::REFACTOR_EXTRACT,
764 CodeActionKind::SOURCE,
765 ])
766 }
767
768 async fn label_for_completion(
769 &self,
770 item: &lsp::CompletionItem,
771 language: &Arc<language::Language>,
772 ) -> Option<language::CodeLabel> {
773 use lsp::CompletionItemKind as Kind;
774 let label_len = item.label.len();
775 let grammar = language.grammar()?;
776 let highlight_id = match item.kind? {
777 Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
778 Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
779 Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
780 Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
781 Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
782 Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
783 _ => None,
784 }?;
785
786 let text = if let Some(description) = item
787 .label_details
788 .as_ref()
789 .and_then(|label_details| label_details.description.as_ref())
790 {
791 format!("{} {}", item.label, description)
792 } else if let Some(detail) = &item.detail {
793 format!("{} {}", item.label, detail)
794 } else {
795 item.label.clone()
796 };
797 Some(language::CodeLabel::filtered(
798 text,
799 label_len,
800 item.filter_text.as_deref(),
801 vec![(0..label_len, highlight_id)],
802 ))
803 }
804
805 async fn initialization_options(
806 self: Arc<Self>,
807 adapter: &Arc<dyn LspAdapterDelegate>,
808 ) -> Result<Option<serde_json::Value>> {
809 let tsdk_path = self.tsdk_path(adapter).await;
810 Ok(Some(json!({
811 "provideFormatter": true,
812 "hostInfo": "zed",
813 "tsserver": {
814 "path": tsdk_path,
815 },
816 "preferences": {
817 "includeInlayParameterNameHints": "all",
818 "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
819 "includeInlayFunctionParameterTypeHints": true,
820 "includeInlayVariableTypeHints": true,
821 "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
822 "includeInlayPropertyDeclarationTypeHints": true,
823 "includeInlayFunctionLikeReturnTypeHints": true,
824 "includeInlayEnumMemberValueHints": true,
825 }
826 })))
827 }
828
829 async fn workspace_configuration(
830 self: Arc<Self>,
831 delegate: &Arc<dyn LspAdapterDelegate>,
832 _: Option<Toolchain>,
833 _: Option<Uri>,
834 cx: &mut AsyncApp,
835 ) -> Result<Value> {
836 let override_options = cx.update(|cx| {
837 language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
838 .and_then(|s| s.settings.clone())
839 });
840 if let Some(options) = override_options {
841 return Ok(options);
842 }
843 Ok(json!({
844 "completions": {
845 "completeFunctionCalls": true
846 }
847 }))
848 }
849
850 fn language_ids(&self) -> HashMap<LanguageName, String> {
851 HashMap::from_iter([
852 (LanguageName::new_static("TypeScript"), "typescript".into()),
853 (LanguageName::new_static("JavaScript"), "javascript".into()),
854 (LanguageName::new_static("TSX"), "typescriptreact".into()),
855 ])
856 }
857}
858
859async fn get_cached_ts_server_binary(
860 container_dir: PathBuf,
861 node: &NodeRuntime,
862) -> Option<LanguageServerBinary> {
863 maybe!(async {
864 let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
865 let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
866 if new_server_path.exists() {
867 Ok(LanguageServerBinary {
868 path: node.binary_path().await?,
869 env: None,
870 arguments: typescript_server_binary_arguments(&new_server_path),
871 })
872 } else if old_server_path.exists() {
873 Ok(LanguageServerBinary {
874 path: node.binary_path().await?,
875 env: None,
876 arguments: typescript_server_binary_arguments(&old_server_path),
877 })
878 } else {
879 anyhow::bail!("missing executable in directory {container_dir:?}")
880 }
881 })
882 .await
883 .log_err()
884}
885
886#[cfg(test)]
887mod tests {
888 use std::path::Path;
889
890 use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
891 use project::FakeFs;
892 use serde_json::json;
893 use task::TaskTemplates;
894 use unindent::Unindent;
895 use util::{path, rel_path::rel_path};
896
897 use crate::typescript::{
898 PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
899 };
900
901 #[gpui::test]
902 async fn test_outline(cx: &mut TestAppContext) {
903 for language in [
904 crate::language(
905 "typescript",
906 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
907 ),
908 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
909 ] {
910 let text = r#"
911 function a() {
912 // local variables are included
913 let a1 = 1;
914 // all functions are included
915 async function a2() {}
916 }
917 // top-level variables are included
918 let b: C
919 function getB() {}
920 // exported variables are included
921 export const d = e;
922 "#
923 .unindent();
924
925 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
926 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
927 assert_eq!(
928 outline
929 .items
930 .iter()
931 .map(|item| (item.text.as_str(), item.depth))
932 .collect::<Vec<_>>(),
933 &[
934 ("function a()", 0),
935 ("let a1", 1),
936 ("async function a2()", 1),
937 ("let b", 0),
938 ("function getB()", 0),
939 ("const d", 0),
940 ]
941 );
942 }
943 }
944
945 #[gpui::test]
946 async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
947 for language in [
948 crate::language(
949 "typescript",
950 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
951 ),
952 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
953 ] {
954 let text = r#"
955 // Top-level destructuring
956 const { a1, a2 } = a;
957 const [b1, b2] = b;
958
959 // Defaults and rest
960 const [c1 = 1, , c2, ...rest1] = c;
961 const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
962
963 function processData() {
964 // Nested object destructuring
965 const { c1, c2 } = c;
966 // Nested array destructuring
967 const [d1, d2, d3] = d;
968 // Destructuring with renaming
969 const { f1: g1 } = f;
970 // With defaults
971 const [x = 10, y] = xy;
972 }
973
974 class DataHandler {
975 method() {
976 // Destructuring in class method
977 const { a1, a2 } = a;
978 const [b1, ...b2] = b;
979 }
980 }
981 "#
982 .unindent();
983
984 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
985 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
986 assert_eq!(
987 outline
988 .items
989 .iter()
990 .map(|item| (item.text.as_str(), item.depth))
991 .collect::<Vec<_>>(),
992 &[
993 ("const a1", 0),
994 ("const a2", 0),
995 ("const b1", 0),
996 ("const b2", 0),
997 ("const c1", 0),
998 ("const c2", 0),
999 ("const rest1", 0),
1000 ("const d1", 0),
1001 ("const e1", 0),
1002 ("const h1", 0),
1003 ("const rest2", 0),
1004 ("function processData()", 0),
1005 ("const c1", 1),
1006 ("const c2", 1),
1007 ("const d1", 1),
1008 ("const d2", 1),
1009 ("const d3", 1),
1010 ("const g1", 1),
1011 ("const x", 1),
1012 ("const y", 1),
1013 ("class DataHandler", 0),
1014 ("method()", 1),
1015 ("const a1", 2),
1016 ("const a2", 2),
1017 ("const b1", 2),
1018 ("const b2", 2),
1019 ]
1020 );
1021 }
1022 }
1023
1024 #[gpui::test]
1025 async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1026 for language in [
1027 crate::language(
1028 "typescript",
1029 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1030 ),
1031 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1032 ] {
1033 let text = r#"
1034 // Object with function properties
1035 const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1036
1037 // Object with primitive properties
1038 const p = { p1: 1, p2: "hello", p3: true };
1039
1040 // Nested objects
1041 const q = {
1042 r: {
1043 // won't be included due to one-level depth limit
1044 s: 1
1045 },
1046 t: 2
1047 };
1048
1049 function getData() {
1050 const local = { x: 1, y: 2 };
1051 return local;
1052 }
1053 "#
1054 .unindent();
1055
1056 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1057 cx.run_until_parked();
1058 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1059 assert_eq!(
1060 outline
1061 .items
1062 .iter()
1063 .map(|item| (item.text.as_str(), item.depth))
1064 .collect::<Vec<_>>(),
1065 &[
1066 ("const o", 0),
1067 ("m()", 1),
1068 ("async n()", 1),
1069 ("g", 1),
1070 ("h", 1),
1071 ("k", 1),
1072 ("const p", 0),
1073 ("p1", 1),
1074 ("p2", 1),
1075 ("p3", 1),
1076 ("const q", 0),
1077 ("r", 1),
1078 ("s", 2),
1079 ("t", 1),
1080 ("function getData()", 0),
1081 ("const local", 1),
1082 ("x", 2),
1083 ("y", 2),
1084 ]
1085 );
1086 }
1087 }
1088
1089 #[gpui::test]
1090 async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1091 for language in [
1092 crate::language(
1093 "typescript",
1094 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1095 ),
1096 crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1097 ] {
1098 let text = r#"
1099 // Symbols as object keys
1100 const sym = Symbol("test");
1101 const obj1 = {
1102 [sym]: 1,
1103 [Symbol("inline")]: 2,
1104 normalKey: 3
1105 };
1106
1107 // Enums as object keys
1108 enum Color { Red, Blue, Green }
1109
1110 const obj2 = {
1111 [Color.Red]: "red value",
1112 [Color.Blue]: "blue value",
1113 regularProp: "normal"
1114 };
1115
1116 // Mixed computed properties
1117 const key = "dynamic";
1118 const obj3 = {
1119 [key]: 1,
1120 ["string" + "concat"]: 2,
1121 [1 + 1]: 3,
1122 static: 4
1123 };
1124
1125 // Nested objects with computed properties
1126 const obj4 = {
1127 [sym]: {
1128 nested: 1
1129 },
1130 regular: {
1131 [key]: 2
1132 }
1133 };
1134 "#
1135 .unindent();
1136
1137 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1138 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1139 assert_eq!(
1140 outline
1141 .items
1142 .iter()
1143 .map(|item| (item.text.as_str(), item.depth))
1144 .collect::<Vec<_>>(),
1145 &[
1146 ("const sym", 0),
1147 ("const obj1", 0),
1148 ("[sym]", 1),
1149 ("[Symbol(\"inline\")]", 1),
1150 ("normalKey", 1),
1151 ("enum Color", 0),
1152 ("const obj2", 0),
1153 ("[Color.Red]", 1),
1154 ("[Color.Blue]", 1),
1155 ("regularProp", 1),
1156 ("const key", 0),
1157 ("const obj3", 0),
1158 ("[key]", 1),
1159 ("[\"string\" + \"concat\"]", 1),
1160 ("[1 + 1]", 1),
1161 ("static", 1),
1162 ("const obj4", 0),
1163 ("[sym]", 1),
1164 ("nested", 2),
1165 ("regular", 1),
1166 ("[key]", 2),
1167 ]
1168 );
1169 }
1170 }
1171
1172 #[gpui::test]
1173 async fn test_generator_function_outline(cx: &mut TestAppContext) {
1174 let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1175
1176 let text = r#"
1177 function normalFunction() {
1178 console.log("normal");
1179 }
1180
1181 function* simpleGenerator() {
1182 yield 1;
1183 yield 2;
1184 }
1185
1186 async function* asyncGenerator() {
1187 yield await Promise.resolve(1);
1188 }
1189
1190 function* generatorWithParams(start, end) {
1191 for (let i = start; i <= end; i++) {
1192 yield i;
1193 }
1194 }
1195
1196 class TestClass {
1197 *methodGenerator() {
1198 yield "method";
1199 }
1200
1201 async *asyncMethodGenerator() {
1202 yield "async method";
1203 }
1204 }
1205 "#
1206 .unindent();
1207
1208 let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1209 let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1210 assert_eq!(
1211 outline
1212 .items
1213 .iter()
1214 .map(|item| (item.text.as_str(), item.depth))
1215 .collect::<Vec<_>>(),
1216 &[
1217 ("function normalFunction()", 0),
1218 ("function* simpleGenerator()", 0),
1219 ("async function* asyncGenerator()", 0),
1220 ("function* generatorWithParams( )", 0),
1221 ("class TestClass", 0),
1222 ("*methodGenerator()", 1),
1223 ("async *asyncMethodGenerator()", 1),
1224 ]
1225 );
1226 }
1227
1228 #[gpui::test]
1229 async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1230 cx.update(|cx| {
1231 settings::init(cx);
1232 });
1233
1234 let package_json_1 = json!({
1235 "dependencies": {
1236 "mocha": "1.0.0",
1237 "vitest": "1.0.0"
1238 },
1239 "scripts": {
1240 "test": ""
1241 }
1242 })
1243 .to_string();
1244
1245 let package_json_2 = json!({
1246 "devDependencies": {
1247 "vitest": "2.0.0"
1248 },
1249 "scripts": {
1250 "test": ""
1251 }
1252 })
1253 .to_string();
1254
1255 let fs = FakeFs::new(executor);
1256 fs.insert_tree(
1257 path!("/root"),
1258 json!({
1259 "package.json": package_json_1,
1260 "sub": {
1261 "package.json": package_json_2,
1262 "file.js": "",
1263 }
1264 }),
1265 )
1266 .await;
1267
1268 let provider = TypeScriptContextProvider::new(fs.clone());
1269 let package_json_data = cx
1270 .update(|cx| {
1271 provider.combined_package_json_data(
1272 fs.clone(),
1273 path!("/root").as_ref(),
1274 rel_path("sub/file1.js"),
1275 cx,
1276 )
1277 })
1278 .await
1279 .unwrap();
1280 pretty_assertions::assert_eq!(
1281 package_json_data,
1282 PackageJsonData {
1283 jest_package_path: None,
1284 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1285 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1286 jasmine_package_path: None,
1287 bun_package_path: None,
1288 node_package_path: None,
1289 scripts: [
1290 (
1291 Path::new(path!("/root/package.json")).into(),
1292 "test".to_owned()
1293 ),
1294 (
1295 Path::new(path!("/root/sub/package.json")).into(),
1296 "test".to_owned()
1297 )
1298 ]
1299 .into_iter()
1300 .collect(),
1301 package_manager: None,
1302 }
1303 );
1304
1305 let mut task_templates = TaskTemplates::default();
1306 package_json_data.fill_task_templates(&mut task_templates);
1307 let task_templates = task_templates
1308 .0
1309 .into_iter()
1310 .map(|template| (template.label, template.cwd))
1311 .collect::<Vec<_>>();
1312 pretty_assertions::assert_eq!(
1313 task_templates,
1314 [
1315 (
1316 "vitest file test".into(),
1317 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1318 ),
1319 (
1320 "vitest test $ZED_SYMBOL".into(),
1321 Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1322 ),
1323 (
1324 "mocha file test".into(),
1325 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1326 ),
1327 (
1328 "mocha test $ZED_SYMBOL".into(),
1329 Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1330 ),
1331 (
1332 "root/package.json > test".into(),
1333 Some(path!("/root").into())
1334 ),
1335 (
1336 "sub/package.json > test".into(),
1337 Some(path!("/root/sub").into())
1338 ),
1339 ]
1340 );
1341 }
1342
1343 #[test]
1344 fn test_escaping_name() {
1345 let cases = [
1346 ("plain test name", "plain test name"),
1347 ("test name with $param_name", "test name with (.+?)"),
1348 ("test name with $nested.param.name", "test name with (.+?)"),
1349 ("test name with $#", "test name with (.+?)"),
1350 ("test name with $##", "test name with (.+?)\\#"),
1351 ("test name with %p", "test name with (.+?)"),
1352 ("test name with %s", "test name with (.+?)"),
1353 ("test name with %d", "test name with (.+?)"),
1354 ("test name with %i", "test name with (.+?)"),
1355 ("test name with %f", "test name with (.+?)"),
1356 ("test name with %j", "test name with (.+?)"),
1357 ("test name with %o", "test name with (.+?)"),
1358 ("test name with %#", "test name with (.+?)"),
1359 ("test name with %$", "test name with (.+?)"),
1360 ("test name with %%", "test name with (.+?)"),
1361 ("test name with %q", "test name with %q"),
1362 (
1363 "test name with regex chars .*+?^${}()|[]\\",
1364 "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1365 ),
1366 (
1367 "test name with multiple $params and %pretty and %b and (.+?)",
1368 "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1369 ),
1370 ];
1371
1372 for (input, expected) in cases {
1373 assert_eq!(replace_test_name_parameters(input), expected);
1374 }
1375 }
1376
1377 // The order of test runner tasks is based on inferred user preference:
1378 // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1379 // 2. Bun's built-in test runner (`bun test`) comes next.
1380 // 3. Node.js's built-in test runner (`node --test`) is last.
1381 // This hierarchy assumes that if a dedicated test framework is installed, it is the
1382 // preferred testing mechanism. Between runtime-specific options, `bun test` is
1383 // typically preferred over `node --test` when @types/bun is present.
1384 #[gpui::test]
1385 async fn test_task_ordering_with_multiple_test_runners(
1386 executor: BackgroundExecutor,
1387 cx: &mut TestAppContext,
1388 ) {
1389 cx.update(|cx| {
1390 settings::init(cx);
1391 });
1392
1393 // Test case with all test runners present
1394 let package_json_all_runners = json!({
1395 "devDependencies": {
1396 "@types/bun": "1.0.0",
1397 "@types/node": "^20.0.0",
1398 "jest": "29.0.0",
1399 "mocha": "10.0.0",
1400 "vitest": "1.0.0",
1401 "jasmine": "5.0.0",
1402 },
1403 "scripts": {
1404 "test": "jest"
1405 }
1406 })
1407 .to_string();
1408
1409 let fs = FakeFs::new(executor);
1410 fs.insert_tree(
1411 path!("/root"),
1412 json!({
1413 "package.json": package_json_all_runners,
1414 "file.js": "",
1415 }),
1416 )
1417 .await;
1418
1419 let provider = TypeScriptContextProvider::new(fs.clone());
1420
1421 let package_json_data = cx
1422 .update(|cx| {
1423 provider.combined_package_json_data(
1424 fs.clone(),
1425 path!("/root").as_ref(),
1426 rel_path("file.js"),
1427 cx,
1428 )
1429 })
1430 .await
1431 .unwrap();
1432
1433 assert!(package_json_data.jest_package_path.is_some());
1434 assert!(package_json_data.mocha_package_path.is_some());
1435 assert!(package_json_data.vitest_package_path.is_some());
1436 assert!(package_json_data.jasmine_package_path.is_some());
1437 assert!(package_json_data.bun_package_path.is_some());
1438 assert!(package_json_data.node_package_path.is_some());
1439
1440 let mut task_templates = TaskTemplates::default();
1441 package_json_data.fill_task_templates(&mut task_templates);
1442
1443 let test_tasks: Vec<_> = task_templates
1444 .0
1445 .iter()
1446 .filter(|template| {
1447 template.tags.contains(&"ts-test".to_owned())
1448 || template.tags.contains(&"js-test".to_owned())
1449 })
1450 .map(|template| &template.label)
1451 .collect();
1452
1453 let node_test_index = test_tasks
1454 .iter()
1455 .position(|label| label.contains("node test"));
1456 let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1457 let bun_test_index = test_tasks
1458 .iter()
1459 .position(|label| label.contains("bun test"));
1460
1461 assert!(
1462 node_test_index.is_some(),
1463 "Node test tasks should be present"
1464 );
1465 assert!(
1466 jest_test_index.is_some(),
1467 "Jest test tasks should be present"
1468 );
1469 assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1470
1471 assert!(
1472 jest_test_index.unwrap() < bun_test_index.unwrap(),
1473 "Jest should come before Bun"
1474 );
1475 assert!(
1476 bun_test_index.unwrap() < node_test_index.unwrap(),
1477 "Bun should come before Node"
1478 );
1479 }
1480}