Repository: denoland/deno
Pull request: fix(test): dedupe discovered workspace test modules

This PR should address #25949.

I fixed duplicate test module execution caused by overlapping discovery entries (e.g., workspace + `deno test .`).

IMHO this is a suboptimal but safer fix. The cleaner solution would be to make discovery non-overlapping at member-pattern expansion time, but changing that area seems more likely to introduce breaking behavior for existing workflows. Given the small scope of the bug, I preferred the lower-risk fix.

Note: this adds a small discovery-time overhead (an extra map pass and some allocations for deduplication). IMHO it’s acceptable for now, but I’d appreciate feedback on whether a follow-up should move deduplication earlier in discovery.

Changed files:
filename: cli/tools/test/mod.rs
patch: @@ -129,6 +129,10 @@ pub enum TestMode {
 }
 
 impl TestMode {
+  fn union(self, other: Self) -> Self {
+    if self == other { self } else { Self::Both }
+  }
+
   /// Returns `true` if the test mode indicates that code snippet extraction is
   /// needed.
   fn needs_test_extraction(&self) -> bool {
@@ -1562,14 +1566,29 @@ async fn fetch_specifiers_with_test_mode(
   member_patterns: impl Iterator<Item = FilePatterns>,
   doc: &bool,
 ) -> Result<Vec<(ModuleSpecifier, TestMode)>, AnyError> {
-  let mut specifiers_with_mode = member_patterns
+  let mut deduped_specifiers_with_mode: IndexMap<ModuleSpecifier, TestMode> =
+    IndexMap::new();
+  for (specifier, mode) in member_patterns
     .map(|files| {
       collect_specifiers_with_test_mode(cli_options, files.clone(), doc)
     })
     .collect::<Result<Vec<_>, _>>()?
     .into_iter()
     .flatten()
-    .collect::<Vec<_>>();
+  {
+    match deduped_specifiers_with_mode.entry(specifier) {
+      indexmap::map::Entry::Occupied(mut entry) => {
+        let merged_mode = entry.get().clone().union(mode);
+        entry.insert(merged_mode);
+      }
+      indexmap::map::Entry::Vacant(entry) => {
+        entry.insert(mode);
+      }
+    }
+  }
+
+  let mut specifiers_with_mode =
+    deduped_specifiers_with_mode.into_iter().collect::<Vec<_>>();
 
   for (specifier, mode) in &mut specifiers_with_mode {
     let file = file_fetcher.fetch_bypass_permissions(specifier).await?;

filename: tests/integration/test_tests.rs
patch: @@ -120,6 +120,25 @@ fn conditionally_loads_type_graph() {
   assert_not_contains!(output.combined_output(), "type_reference.d.ts");
 }
 
+#[test]
+fn workspace_dot_runs_test_module_once() {
+  let context = TestContextBuilder::new().use_temp_cwd().build();
+  let temp_dir = context.temp_dir();
+  temp_dir.write("deno.jsonc", r#"{ "workspace": ["./member"] }"#);
+  temp_dir.write("member/deno.json", "{}");
+  temp_dir.write("main_test.ts", "Deno.test('test', () => {});");
+
+  let output = context.new_command().args("test .").run();
+  output.assert_exit_code(0);
+
+  let text = output.combined_output();
+  let test_file_runs = text.matches("running 1 test from").count();
+  assert_eq!(
+    test_file_runs, 1,
+    "expected test module to run once, but saw {test_file_runs} runs:\n{text}"
+  );
+}
+
 #[test]
 fn nested_deno_test_registration_errors() {
   let context = TestContextBuilder::new().use_temp_cwd().build();