typescript: Runners support for `bun:test` & `node:test` (#39238)

versecafe created

Closes #21132

Release Notes:

- JavaScript/TypeScript: Added support for detecting `node:test` and `bun:test` test runners

Change summary

crates/languages/src/package_json.rs |  22 +++
crates/languages/src/typescript.rs   | 194 ++++++++++++++++++++++++++++++
docs/src/languages/javascript.md     |   6 
docs/src/languages/typescript.md     |   6 
4 files changed, 226 insertions(+), 2 deletions(-)

Detailed changes

crates/languages/src/package_json.rs 🔗

@@ -15,6 +15,8 @@ pub struct PackageJsonData {
     pub mocha_package_path: Option<Arc<Path>>,
     pub vitest_package_path: Option<Arc<Path>>,
     pub jasmine_package_path: Option<Arc<Path>>,
+    pub bun_package_path: Option<Arc<Path>>,
+    pub node_package_path: Option<Arc<Path>>,
     pub scripts: BTreeSet<(Arc<Path>, String)>,
     pub package_manager: Option<&'static str>,
 }
@@ -35,6 +37,8 @@ impl PackageJsonData {
         let mut mocha_package_path = None;
         let mut vitest_package_path = None;
         let mut jasmine_package_path = None;
+        let mut bun_package_path = None;
+        let mut node_package_path = None;
         if let Some(Value::Object(dependencies)) = package_json.get("devDependencies") {
             if dependencies.contains_key("jest") {
                 jest_package_path.get_or_insert_with(|| path.clone());
@@ -48,6 +52,12 @@ impl PackageJsonData {
             if dependencies.contains_key("jasmine") {
                 jasmine_package_path.get_or_insert_with(|| path.clone());
             }
+            if dependencies.contains_key("@types/bun") {
+                bun_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dependencies.contains_key("@types/node") {
+                node_package_path.get_or_insert_with(|| path.clone());
+            }
         }
         if let Some(Value::Object(dev_dependencies)) = package_json.get("dependencies") {
             if dev_dependencies.contains_key("jest") {
@@ -62,6 +72,12 @@ impl PackageJsonData {
             if dev_dependencies.contains_key("jasmine") {
                 jasmine_package_path.get_or_insert_with(|| path.clone());
             }
+            if dev_dependencies.contains_key("@types/bun") {
+                bun_package_path.get_or_insert_with(|| path.clone());
+            }
+            if dev_dependencies.contains_key("@types/node") {
+                node_package_path.get_or_insert_with(|| path.clone());
+            }
         }
 
         let package_manager = package_json
@@ -74,6 +90,8 @@ impl PackageJsonData {
                     Some("yarn")
                 } else if value.starts_with("npm") {
                     Some("npm")
+                } else if value.starts_with("bun") {
+                    Some("bun")
                 } else {
                     None
                 }
@@ -84,6 +102,8 @@ impl PackageJsonData {
             mocha_package_path,
             vitest_package_path,
             jasmine_package_path,
+            bun_package_path,
+            node_package_path,
             scripts,
             package_manager,
         }
@@ -100,6 +120,8 @@ impl PackageJsonData {
             .jasmine_package_path
             .take()
             .or(other.jasmine_package_path);
+        self.bun_package_path = self.bun_package_path.take().or(other.bun_package_path);
+        self.node_package_path = self.node_package_path.take().or(other.node_package_path);
         self.scripts.extend(other.scripts);
         self.package_manager = self.package_manager.or(other.package_manager);
     }

crates/languages/src/typescript.rs 🔗

@@ -54,6 +54,12 @@ const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
 const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
     VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
 
+const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
+
+const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
+    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
+
 #[derive(Clone, Debug, Default)]
 struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
 
@@ -220,6 +226,65 @@ impl PackageJsonData {
             });
         }
 
+        if self.bun_package_path.is_some() {
+            task_templates.0.push(TaskTemplate {
+                label: format!("{} file test", "bun test".to_owned()),
+                command: "bun".to_owned(),
+                args: vec!["test".to_owned(), VariableName::File.template_value()],
+                cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+            task_templates.0.push(TaskTemplate {
+                label: format!("bun test {}", VariableName::Symbol.template_value(),),
+                command: "bun".to_owned(),
+                args: vec![
+                    "test".to_owned(),
+                    "--test-name-pattern".to_owned(),
+                    format!("\"{}\"", VariableName::Symbol.template_value()),
+                    VariableName::File.template_value(),
+                ],
+                tags: vec![
+                    "ts-test".to_owned(),
+                    "js-test".to_owned(),
+                    "tsx-test".to_owned(),
+                ],
+                cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+        }
+
+        if self.node_package_path.is_some() {
+            task_templates.0.push(TaskTemplate {
+                label: format!("{} file test", "node test".to_owned()),
+                command: "node".to_owned(),
+                args: vec!["--test".to_owned(), VariableName::File.template_value()],
+                tags: vec![
+                    "ts-test".to_owned(),
+                    "js-test".to_owned(),
+                    "tsx-test".to_owned(),
+                ],
+                cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+            task_templates.0.push(TaskTemplate {
+                label: format!("node test {}", VariableName::Symbol.template_value()),
+                command: "node".to_owned(),
+                args: vec![
+                    "--test".to_owned(),
+                    "--test-name-pattern".to_owned(),
+                    format!("\"{}\"", VariableName::Symbol.template_value()),
+                    VariableName::File.template_value(),
+                ],
+                tags: vec![
+                    "ts-test".to_owned(),
+                    "js-test".to_owned(),
+                    "tsx-test".to_owned(),
+                ],
+                cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
+                ..TaskTemplate::default()
+            });
+        }
+
         let script_name_counts: HashMap<_, usize> =
             self.scripts
                 .iter()
@@ -493,6 +558,26 @@ impl ContextProvider for TypeScriptContextProvider {
                                 .to_string(),
                         );
                     }
+
+                    if let Some(path) = package_json_data.bun_package_path {
+                        vars.insert(
+                            TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
+                            path.parent()
+                                .unwrap_or(Path::new(""))
+                                .to_string_lossy()
+                                .to_string(),
+                        );
+                    }
+
+                    if let Some(path) = package_json_data.node_package_path {
+                        vars.insert(
+                            TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
+                            path.parent()
+                                .unwrap_or(Path::new(""))
+                                .to_string_lossy()
+                                .to_string(),
+                        );
+                    }
                 }
             }
             Ok(vars)
@@ -1178,6 +1263,8 @@ mod tests {
                 mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
                 vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
                 jasmine_package_path: None,
+                bun_package_path: None,
+                node_package_path: None,
                 scripts: [
                     (
                         Path::new(path!("/root/package.json")).into(),
@@ -1231,6 +1318,7 @@ mod tests {
             ]
         );
     }
+
     #[test]
     fn test_escaping_name() {
         let cases = [
@@ -1264,4 +1352,110 @@ mod tests {
             assert_eq!(replace_test_name_parameters(input), expected);
         }
     }
+
+    // The order of test runner tasks is based on inferred user preference:
+    // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
+    // 2. Bun's built-in test runner (`bun test`) comes next.
+    // 3. Node.js's built-in test runner (`node --test`) is last.
+    // This hierarchy assumes that if a dedicated test framework is installed, it is the
+    // preferred testing mechanism. Between runtime-specific options, `bun test` is
+    // typically preferred over `node --test` when @types/bun is present.
+    #[gpui::test]
+    async fn test_task_ordering_with_multiple_test_runners(
+        executor: BackgroundExecutor,
+        cx: &mut TestAppContext,
+    ) {
+        cx.update(|cx| {
+            settings::init(cx);
+            Project::init_settings(cx);
+            language_settings::init(cx);
+        });
+
+        // Test case with all test runners present
+        let package_json_all_runners = json!({
+            "devDependencies": {
+                "@types/bun": "1.0.0",
+                "@types/node": "^20.0.0",
+                "jest": "29.0.0",
+                "mocha": "10.0.0",
+                "vitest": "1.0.0",
+                "jasmine": "5.0.0",
+            },
+            "scripts": {
+                "test": "jest"
+            }
+        })
+        .to_string();
+
+        let fs = FakeFs::new(executor);
+        fs.insert_tree(
+            path!("/root"),
+            json!({
+                "package.json": package_json_all_runners,
+                "file.js": "",
+            }),
+        )
+        .await;
+
+        let provider = TypeScriptContextProvider::new(fs.clone());
+
+        let package_json_data = cx
+            .update(|cx| {
+                provider.combined_package_json_data(
+                    fs.clone(),
+                    path!("/root").as_ref(),
+                    rel_path("file.js"),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+
+        assert!(package_json_data.jest_package_path.is_some());
+        assert!(package_json_data.mocha_package_path.is_some());
+        assert!(package_json_data.vitest_package_path.is_some());
+        assert!(package_json_data.jasmine_package_path.is_some());
+        assert!(package_json_data.bun_package_path.is_some());
+        assert!(package_json_data.node_package_path.is_some());
+
+        let mut task_templates = TaskTemplates::default();
+        package_json_data.fill_task_templates(&mut task_templates);
+
+        let test_tasks: Vec<_> = task_templates
+            .0
+            .iter()
+            .filter(|template| {
+                template.tags.contains(&"ts-test".to_owned())
+                    || template.tags.contains(&"js-test".to_owned())
+            })
+            .map(|template| &template.label)
+            .collect();
+
+        let node_test_index = test_tasks
+            .iter()
+            .position(|label| label.contains("node test"));
+        let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
+        let bun_test_index = test_tasks
+            .iter()
+            .position(|label| label.contains("bun test"));
+
+        assert!(
+            node_test_index.is_some(),
+            "Node test tasks should be present"
+        );
+        assert!(
+            jest_test_index.is_some(),
+            "Jest test tasks should be present"
+        );
+        assert!(bun_test_index.is_some(), "Bun test tasks should be present");
+
+        assert!(
+            jest_test_index.unwrap() < bun_test_index.unwrap(),
+            "Jest should come before Bun"
+        );
+        assert!(
+            bun_test_index.unwrap() < node_test_index.unwrap(),
+            "Bun should come before Node"
+        );
+    }
 }

docs/src/languages/javascript.md 🔗

@@ -179,10 +179,14 @@ Zed supports debugging JavaScript code out of the box.
 The following can be debugged without writing additional configuration:
 
 - Tasks from `package.json`
-- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine)
+- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine, Bun, Node)
 
 Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks.
 
+> **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`.
+>
+> **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+).
+
 As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed.
 
 If your use-case isn't covered by any of these, you can take full control by adding debug configurations to `.zed/debug.json`. See below for example configurations.

docs/src/languages/typescript.md 🔗

@@ -162,10 +162,14 @@ Zed supports debugging TypeScript code out of the box.
 The following can be debugged without writing additional configuration:
 
 - Tasks from `package.json`
-- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine)
+- Tests written using several popular frameworks (Jest, Mocha, Vitest, Jasmine, Bun, Node)
 
 Run {#action debugger::Start} ({#kb debugger::Start}) to see a contextual list of these predefined debug tasks.
 
+> **Note:** Bun test is automatically detected when `@types/bun` is present in `package.json`.
+>
+> **Note:** Node test is automatically detected when `@types/node` is present in `package.json` (requires Node.js 20+).
+
 As for all languages, configurations from `.vscode/launch.json` are also available for debugging in Zed.
 
 If your use-case isn't covered by any of these, you can take full control by adding debug configurations to `.zed/debug.json`. See below for example configurations.