debugger: Fix running JS tests when worktree root and package root do not coincide (#32644)

- construct the correct path to the test library based on the location
of package.json
- run scripts from the package root where they were defined
- run tests in the directory of the defining file

Release Notes:

- Debugger Beta: fixed running JS tests when the worktree root is above
the location of package.json.

---------

Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
Cole Miller 2025-06-13 01:03:07 -04:00 committed by GitHub
parent 9166e66519
commit bcd79331b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 289 additions and 112 deletions

View file

@ -8,8 +8,7 @@ use futures::future::join_all;
use gpui::{App, AppContext, AsyncApp, Task};
use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
use language::{
ContextLocation, ContextProvider, File, LanguageToolchainStore, LocalFile, LspAdapter,
LspAdapterDelegate,
ContextLocation, ContextProvider, File, LanguageToolchainStore, LspAdapter, LspAdapterDelegate,
};
use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
use node_runtime::NodeRuntime;
@ -29,6 +28,7 @@ use util::archive::extract_zip;
use util::merge_json_value_into;
use util::{ResultExt, fs::remove_matching, maybe};
#[derive(Debug)]
pub(crate) struct TypeScriptContextProvider {
last_package_json: PackageJsonContents,
}
@ -42,47 +42,81 @@ const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
#[derive(Clone, Default)]
const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
#[derive(Clone, Debug, Default)]
struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
#[derive(Clone, Debug)]
struct PackageJson {
mtime: DateTime<Local>,
data: PackageJsonData,
}
#[derive(Clone, Default)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct PackageJsonData {
jest: bool,
mocha: bool,
vitest: bool,
jasmine: bool,
scripts: BTreeSet<String>,
jest_package_path: Option<Arc<Path>>,
mocha_package_path: Option<Arc<Path>>,
vitest_package_path: Option<Arc<Path>>,
jasmine_package_path: Option<Arc<Path>>,
scripts: BTreeSet<(Arc<Path>, String)>,
package_manager: Option<&'static str>,
}
impl PackageJsonData {
fn new(package_json: HashMap<String, Value>) -> Self {
fn new(path: Arc<Path>, package_json: HashMap<String, Value>) -> Self {
let mut scripts = BTreeSet::new();
if let Some(serde_json::Value::Object(package_json_scripts)) = package_json.get("scripts") {
scripts.extend(package_json_scripts.keys().cloned());
scripts.extend(
package_json_scripts
.keys()
.cloned()
.map(|name| (path.clone(), name)),
);
}
let mut jest = false;
let mut mocha = false;
let mut vitest = false;
let mut jasmine = false;
let mut jest_package_path = None;
let mut mocha_package_path = None;
let mut vitest_package_path = None;
let mut jasmine_package_path = None;
if let Some(serde_json::Value::Object(dependencies)) = package_json.get("devDependencies") {
jest |= dependencies.contains_key("jest");
mocha |= dependencies.contains_key("mocha");
vitest |= dependencies.contains_key("vitest");
jasmine |= dependencies.contains_key("jasmine");
if dependencies.contains_key("jest") {
jest_package_path.get_or_insert_with(|| path.clone());
}
if dependencies.contains_key("mocha") {
mocha_package_path.get_or_insert_with(|| path.clone());
}
if dependencies.contains_key("vitest") {
vitest_package_path.get_or_insert_with(|| path.clone());
}
if dependencies.contains_key("jasmine") {
jasmine_package_path.get_or_insert_with(|| path.clone());
}
}
if let Some(serde_json::Value::Object(dev_dependencies)) = package_json.get("dependencies")
{
jest |= dev_dependencies.contains_key("jest");
mocha |= dev_dependencies.contains_key("mocha");
vitest |= dev_dependencies.contains_key("vitest");
jasmine |= dev_dependencies.contains_key("jasmine");
if dev_dependencies.contains_key("jest") {
jest_package_path.get_or_insert_with(|| path.clone());
}
if dev_dependencies.contains_key("mocha") {
mocha_package_path.get_or_insert_with(|| path.clone());
}
if dev_dependencies.contains_key("vitest") {
vitest_package_path.get_or_insert_with(|| path.clone());
}
if dev_dependencies.contains_key("jasmine") {
jasmine_package_path.get_or_insert_with(|| path.clone());
}
}
let package_manager = package_json
@ -101,33 +135,37 @@ impl PackageJsonData {
});
Self {
jest,
mocha,
vitest,
jasmine,
jest_package_path,
mocha_package_path,
vitest_package_path,
jasmine_package_path,
scripts,
package_manager,
}
}
fn merge(&mut self, other: Self) {
self.jest |= other.jest;
self.mocha |= other.mocha;
self.vitest |= other.vitest;
self.jasmine |= other.jasmine;
self.jest_package_path = self.jest_package_path.take().or(other.jest_package_path);
self.mocha_package_path = self.mocha_package_path.take().or(other.mocha_package_path);
self.vitest_package_path = self
.vitest_package_path
.take()
.or(other.vitest_package_path);
self.jasmine_package_path = self
.jasmine_package_path
.take()
.or(other.jasmine_package_path);
self.scripts.extend(other.scripts);
self.package_manager = self.package_manager.or(other.package_manager);
}
fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
if self.jest {
if self.jest_package_path.is_some() {
task_templates.0.push(TaskTemplate {
label: "jest file test".to_owned(),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
"jest".to_owned(),
VariableName::RelativeFile.template_value(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
args: vec!["jest".to_owned(), VariableName::Filename.template_value()],
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
@ -140,28 +178,28 @@ impl PackageJsonData {
"\"{}\"",
TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
),
VariableName::RelativeFile.template_value(),
VariableName::Filename.template_value(),
],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
}
if self.vitest {
if self.vitest_package_path.is_some() {
task_templates.0.push(TaskTemplate {
label: format!("{} file test", "vitest".to_owned()),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
"vitest".to_owned(),
"run".to_owned(),
VariableName::RelativeFile.template_value(),
VariableName::Filename.template_value(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
@ -179,27 +217,24 @@ impl PackageJsonData {
"\"{}\"",
TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
),
VariableName::RelativeFile.template_value(),
VariableName::Filename.template_value(),
],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
}
if self.mocha {
if self.mocha_package_path.is_some() {
task_templates.0.push(TaskTemplate {
label: format!("{} file test", "mocha".to_owned()),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
"mocha".to_owned(),
VariableName::RelativeFile.template_value(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
args: vec!["mocha".to_owned(), VariableName::Filename.template_value()],
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
@ -213,27 +248,27 @@ impl PackageJsonData {
"mocha".to_owned(),
"--grep".to_owned(),
format!("\"{}\"", VariableName::Symbol.template_value()),
VariableName::RelativeFile.template_value(),
VariableName::Filename.template_value(),
],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
}
if self.jasmine {
if self.jasmine_package_path.is_some() {
task_templates.0.push(TaskTemplate {
label: format!("{} file test", "jasmine".to_owned()),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
"jasmine".to_owned(),
VariableName::RelativeFile.template_value(),
VariableName::Filename.template_value(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
task_templates.0.push(TaskTemplate {
@ -246,30 +281,31 @@ impl PackageJsonData {
args: vec![
"jasmine".to_owned(),
format!("--filter={}", VariableName::Symbol.template_value()),
VariableName::RelativeFile.template_value(),
VariableName::Filename.template_value(),
],
tags: vec![
"ts-test".to_owned(),
"js-test".to_owned(),
"tsx-test".to_owned(),
"jasmine-test".to_owned(),
],
cwd: Some(VariableName::WorktreeRoot.template_value()),
cwd: Some(VariableName::Dirname.template_value()),
..TaskTemplate::default()
});
}
for script in &self.scripts {
for (path, script) in &self.scripts {
task_templates.0.push(TaskTemplate {
label: format!("package.json > {script}",),
command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
args: vec![
"--prefix".to_owned(),
VariableName::WorktreeRoot.template_value(),
"run".to_owned(),
script.to_owned(),
],
args: vec!["run".to_owned(), script.to_owned()],
tags: vec!["package-script".into()],
cwd: Some(VariableName::WorktreeRoot.template_value()),
cwd: Some(
path.parent()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string(),
),
..TaskTemplate::default()
});
}
@ -287,13 +323,9 @@ impl TypeScriptContextProvider {
&self,
fs: Arc<dyn Fs>,
worktree_root: &Path,
file_abs_path: &Path,
file_relative_path: &Path,
cx: &App,
) -> Task<anyhow::Result<PackageJsonData>> {
let Some(file_relative_path) = file_abs_path.strip_prefix(&worktree_root).ok() else {
log::debug!("No package json data for off-worktree files");
return Task::ready(Ok(PackageJsonData::default()));
};
let new_json_data = file_relative_path
.ancestors()
.map(|path| worktree_root.join(path))
@ -345,7 +377,8 @@ impl TypeScriptContextProvider {
serde_json::from_str(&package_json_string).with_context(|| {
format!("parsing package.json from {package_json_path:?}")
})?;
let new_data = PackageJsonData::new(package_json);
let new_data =
PackageJsonData::new(package_json_path.as_path().into(), package_json);
{
let mut contents = existing_package_json.0.write().await;
contents.insert(
@ -361,31 +394,25 @@ impl TypeScriptContextProvider {
}
})
}
}
fn detect_package_manager(
&self,
worktree_root: PathBuf,
fs: Arc<dyn Fs>,
cx: &App,
) -> Task<&'static str> {
let last_package_json = self.last_package_json.clone();
let package_json_data =
self.package_json_data(&worktree_root, last_package_json, fs.clone(), cx);
cx.background_spawn(async move {
if let Ok(package_json_data) = package_json_data.await {
if let Some(package_manager) = package_json_data.package_manager {
return package_manager;
}
}
if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
return "pnpm";
}
if fs.is_file(&worktree_root.join("yarn.lock")).await {
return "yarn";
}
"npm"
})
async fn detect_package_manager(
worktree_root: PathBuf,
fs: Arc<dyn Fs>,
package_json_data: Option<PackageJsonData>,
) -> &'static str {
if let Some(package_json_data) = package_json_data {
if let Some(package_manager) = package_json_data.package_manager {
return package_manager;
}
}
if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
return "pnpm";
}
if fs.is_file(&worktree_root.join("yarn.lock")).await {
return "yarn";
}
"npm"
}
impl ContextProvider for TypeScriptContextProvider {
@ -401,9 +428,9 @@ impl ContextProvider for TypeScriptContextProvider {
let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
return Task::ready(None);
};
let file_abs_path = file.abs_path(cx);
let file_relative_path = file.path().clone();
let package_json_data =
self.combined_package_json_data(fs.clone(), &worktree_root, &file_abs_path, cx);
self.combined_package_json_data(fs.clone(), &worktree_root, &file_relative_path, cx);
cx.background_spawn(async move {
let mut task_templates = TaskTemplates(Vec::new());
@ -426,7 +453,7 @@ impl ContextProvider for TypeScriptContextProvider {
}
Err(e) => {
log::error!(
"Failed to read package.json for worktree {file_abs_path:?}: {e:#}"
"Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
);
}
}
@ -455,14 +482,73 @@ impl ContextProvider for TypeScriptContextProvider {
replace_test_name_parameters(symbol),
);
}
let file_path = location
.file_location
.buffer
.read(cx)
.file()
.map(|file| file.path());
let task = location
.worktree_root
.zip(location.fs)
.map(|(worktree_root, fs)| self.detect_package_manager(worktree_root, fs, cx));
let args = location.worktree_root.zip(location.fs).zip(file_path).map(
|((worktree_root, fs), file_path)| {
(
self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
worktree_root,
fs,
)
},
);
cx.background_spawn(async move {
if let Some(task) = task {
vars.insert(TYPESCRIPT_RUNNER_VARIABLE, task.await.to_owned());
if let Some((task, worktree_root, fs)) = args {
let package_json_data = task.await.log_err();
vars.insert(
TYPESCRIPT_RUNNER_VARIABLE,
detect_package_manager(worktree_root, fs, package_json_data.clone())
.await
.to_owned(),
);
if let Some(package_json_data) = package_json_data {
if let Some(path) = package_json_data.jest_package_path {
vars.insert(
TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
path.parent()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string(),
);
}
if let Some(path) = package_json_data.mocha_package_path {
vars.insert(
TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
path.parent()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string(),
);
}
if let Some(path) = package_json_data.vitest_package_path {
vars.insert(
TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
path.parent()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string(),
);
}
if let Some(path) = package_json_data.jasmine_package_path {
vars.insert(
TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
path.parent()
.unwrap_or(Path::new(""))
.to_string_lossy()
.to_string(),
);
}
}
}
Ok(vars)
})
@ -991,8 +1077,16 @@ async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
#[cfg(test)]
mod tests {
use gpui::{AppContext as _, TestAppContext};
use std::path::Path;
use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
use language::language_settings;
use project::{FakeFs, Project};
use serde_json::json;
use unindent::Unindent;
use util::path;
use crate::typescript::{PackageJsonData, TypeScriptContextProvider};
#[gpui::test]
async fn test_outline(cx: &mut TestAppContext) {
@ -1033,4 +1127,82 @@ mod tests {
]
);
}
#[gpui::test]
async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
cx.update(|cx| {
settings::init(cx);
Project::init_settings(cx);
language_settings::init(cx);
});
let package_json_1 = json!({
"dependencies": {
"mocha": "1.0.0",
"vitest": "1.0.0"
},
"scripts": {
"test": ""
}
})
.to_string();
let package_json_2 = json!({
"devDependencies": {
"vitest": "2.0.0"
},
"scripts": {
"test": ""
}
})
.to_string();
let fs = FakeFs::new(executor);
fs.insert_tree(
path!("/root"),
json!({
"package.json": package_json_1,
"sub": {
"package.json": package_json_2,
"file.js": "",
}
}),
)
.await;
let provider = TypeScriptContextProvider::new();
let package_json_data = cx
.update(|cx| {
provider.combined_package_json_data(
fs.clone(),
path!("/root").as_ref(),
"sub/file1.js".as_ref(),
cx,
)
})
.await
.unwrap();
pretty_assertions::assert_eq!(
package_json_data,
PackageJsonData {
jest_package_path: None,
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,
scripts: [
(
Path::new(path!("/root/package.json")).into(),
"test".to_owned()
),
(
Path::new(path!("/root/sub/package.json")).into(),
"test".to_owned()
)
]
.into_iter()
.collect(),
package_manager: None,
}
);
}
}

View file

@ -1,4 +1,4 @@
use std::{borrow::Cow, path::Path};
use std::{borrow::Cow, path::PathBuf};
use anyhow::{Result, bail};
use async_trait::async_trait;
@ -11,8 +11,6 @@ pub(crate) struct NodeLocator;
const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
const TYPESCRIPT_JEST_TASK_VARIABLE: VariableName =
VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST"));
#[async_trait]
impl DapLocator for NodeLocator {
@ -34,14 +32,21 @@ impl DapLocator for NodeLocator {
return None;
}
let test_library = build_config.args.first()?;
let program_path = Path::new("$ZED_WORKTREE_ROOT")
let program_path_base: PathBuf = match test_library.as_str() {
"jest" => "${ZED_CUSTOM_TYPESCRIPT_JEST_PACKAGE_PATH}".to_owned(),
"mocha" => "${ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH}".to_owned(),
"vitest" => "${ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH}".to_owned(),
"jasmine" => "${ZED_CUSTOM_TYPESCRIPT_JASMINE_PACKAGE_PATH}".to_owned(),
_ => VariableName::WorktreeRoot.template_value(),
}
.into();
let program_path = program_path_base
.join("node_modules")
.join(".bin")
.join(test_library);
let mut args = if test_library == "jest"
|| test_library == &TYPESCRIPT_JEST_TASK_VARIABLE.template_value()
{
let mut args = if test_library == "jest" {
vec!["--runInBand".to_owned()]
} else {
vec![]

View file

@ -278,7 +278,7 @@ impl language::LanguageToolchainStore for RemoteStore {
}
}
pub(crate) struct EmptyToolchainStore;
pub struct EmptyToolchainStore;
#[async_trait(?Send)]
impl language::LanguageToolchainStore for EmptyToolchainStore {
async fn active_toolchain(