This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git

commit 933a59c0c86e4ad60f56b591cd2e7a3674eafd13
Author: Paul King <[email protected]>
AuthorDate: Thu May 7 19:13:50 2026 +1000

    GROOVY-11997: Add @ForkedJvm JUnit extension (allow classpath filtering)
---
 .../main/java/groovy/junit6/plugin/ForkedJvm.java  | 19 ++++++++
 .../groovy/junit6/plugin/ForkedJvmExtension.java   | 54 ++++++++++++++++++++-
 .../src/test/groovy/ForkedJvmTest.groovy           | 56 ++++++++++++++++++++++
 3 files changed, 128 insertions(+), 1 deletion(-)

diff --git 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
index 3ba396d40f..52f1a3d0e1 100644
--- 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
@@ -129,4 +129,23 @@ public @interface ForkedJvm {
      * values for the same key.
      */
     String[] inheritProperties() default {};
+
+    /**
+     * Regular expressions that exclude matching entries from the parent's
+     * {@code java.class.path} when constructing the forked JVM's classpath.
+     * Each pattern is applied to each path entry with
+     * {@link java.util.regex.Matcher#find()}, so plain substrings (e.g.
+     * {@code "junit-platform-instrumentation"}) work without anchoring.
+     * <p>
+     * Patterns can also be supplied via the system property
+     * {@code groovy.junit6.forked.excludeClasspath} (comma-separated) for
+     * build- or CI-level configuration without modifying test source.
+     * Annotation values and the system-property values are unioned.
+     * <p>
+     * Limitations: this filter only inspects entries appearing literally
+     * in {@code java.class.path}. Modules on {@code --module-path},
+     * directory wildcards (e.g. {@code lib/*}), and {@code Class-Path:}
+     * manifest entries inherited from another jar are not matched.
+     */
+    String[] excludeFromClasspath() default {};
 }
diff --git 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
index a142af591d..ddf8e85de2 100644
--- 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
@@ -36,6 +36,7 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Properties;
 import java.util.Set;
+import java.util.regex.Pattern;
 
 /**
  * JUnit 5 {@link InvocationInterceptor} backing the {@link ForkedJvm}
@@ -52,6 +53,14 @@ import java.util.Set;
  */
 public class ForkedJvmExtension implements InvocationInterceptor {
 
+    /**
+     * System property naming a comma-separated list of regular expressions
+     * to exclude from the parent's {@code java.class.path} when building
+     * the forked JVM's classpath. Augments
+     * {@link ForkedJvm#excludeFromClasspath()}.
+     */
+    public static final String EXCLUDE_CLASSPATH_PROP = 
"groovy.junit6.forked.excludeClasspath";
+
     @Override
     public void interceptTestMethod(Invocation<Void> invocation,
                                     ReflectiveInvocationContext<Method> 
invocationContext,
@@ -128,13 +137,56 @@ public class ForkedJvmExtension implements 
InvocationInterceptor {
             cmd.add(arg);
         }
         cmd.add("-cp");
-        cmd.add(System.getProperty("java.class.path"));
+        cmd.add(filterClasspath(System.getProperty("java.class.path"), 
resolveExcludes(config)));
         cmd.add(ForkedJvmTestRunner.class.getName());
         cmd.add(testClass.getName());
         cmd.add(testMethod.getName());
         return cmd;
     }
 
+    private static List<Pattern> resolveExcludes(ForkedJvm config) {
+        List<Pattern> excludes = new ArrayList<>();
+        for (String p : config.excludeFromClasspath()) {
+            if (!p.isEmpty()) excludes.add(Pattern.compile(p));
+        }
+        String fromProp = System.getProperty(EXCLUDE_CLASSPATH_PROP, "");
+        for (String p : fromProp.split(",")) {
+            String trimmed = p.trim();
+            if (!trimmed.isEmpty()) excludes.add(Pattern.compile(trimmed));
+        }
+        return excludes;
+    }
+
+    /**
+     * Filters {@code classpath} (a {@link File#pathSeparator}-separated list)
+     * by dropping entries that match any of the supplied regular expressions
+     * via {@link java.util.regex.Matcher#find()}. Visible for testing.
+     *
+     * @param classpath the original classpath string
+     * @param excludes  regex patterns; an empty list returns {@code 
classpath} unchanged
+     * @return the filtered classpath string
+     */
+    public static String filterClasspath(String classpath, List<Pattern> 
excludes) {
+        if (excludes.isEmpty() || classpath == null || classpath.isEmpty()) {
+            return classpath;
+        }
+        StringBuilder result = new StringBuilder(classpath.length());
+        for (String entry : 
classpath.split(Pattern.quote(File.pathSeparator))) {
+            boolean matched = false;
+            for (Pattern p : excludes) {
+                if (p.matcher(entry).find()) {
+                    matched = true;
+                    break;
+                }
+            }
+            if (!matched) {
+                if (result.length() > 0) result.append(File.pathSeparator);
+                result.append(entry);
+            }
+        }
+        return result.toString();
+    }
+
     private static Set<String> resolveInherited(String[] patterns) {
         if (patterns.length == 0) return java.util.Collections.emptySet();
         Properties parent = System.getProperties();
diff --git 
a/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy 
b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
index 766eaded02..a21bbb14c8 100644
--- a/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
+++ b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
@@ -19,11 +19,14 @@
 
 import groovy.junit6.plugin.ExpectedToFail
 import groovy.junit6.plugin.ForkedJvm
+import groovy.junit6.plugin.ForkedJvmExtension
 import org.junit.jupiter.api.AfterAll
 import org.junit.jupiter.api.Assumptions
 import org.junit.jupiter.api.BeforeAll
 import org.junit.jupiter.api.Tag
 import org.junit.jupiter.api.Test
+
+import java.util.regex.Pattern
 import org.junit.platform.engine.discovery.DiscoverySelectors
 import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
 import org.junit.platform.launcher.core.LauncherFactory
@@ -141,6 +144,59 @@ class ForkedJvmTest {
         assertNull(System.getProperty('does.not.exist.in.parent'))
     }
 
+    // ---------------- excludeFromClasspath ----------------
+
+    @Test
+    void filterClasspath_dropsMatchingEntries() {
+        def sep = File.pathSeparator
+        def cp = 
"/a/foo-1.0.jar${sep}/b/junit-platform-instrumentation-1.9.0.jar${sep}/c/bar-2.0.jar"
+        def filtered = ForkedJvmExtension.filterClasspath(cp, 
[Pattern.compile('junit-platform-instrumentation')])
+        assertEquals("/a/foo-1.0.jar${sep}/c/bar-2.0.jar".toString(), filtered)
+    }
+
+    @Test
+    void filterClasspath_supportsMultiplePatterns() {
+        def sep = File.pathSeparator
+        def cp = "/a/foo.jar${sep}/b/bar.jar${sep}/c/baz.jar"
+        def filtered = ForkedJvmExtension.filterClasspath(cp,
+                [Pattern.compile('foo'), Pattern.compile('baz')])
+        assertEquals("/b/bar.jar", filtered)
+    }
+
+    @Test
+    void filterClasspath_unchangedWhenNoMatches() {
+        def sep = File.pathSeparator
+        def cp = "/a/foo-1.0.jar${sep}/b/bar-2.0.jar".toString()
+        def filtered = ForkedJvmExtension.filterClasspath(cp, 
[Pattern.compile('nothing-matches-this')])
+        assertEquals(cp, filtered)
+    }
+
+    @Test
+    void filterClasspath_unchangedWithEmptyPatterns() {
+        def cp = "/a/foo.jar${File.pathSeparator}/b/bar.jar".toString()
+        def filtered = ForkedJvmExtension.filterClasspath(cp, [])
+        assertEquals(cp, filtered)
+    }
+
+    @Test
+    void filterClasspath_handlesNullAndEmpty() {
+        assertNull(ForkedJvmExtension.filterClasspath(null, 
[Pattern.compile('x')]))
+        assertEquals('', ForkedJvmExtension.filterClasspath('', 
[Pattern.compile('x')]))
+    }
+
+    @Test
+    @ForkedJvm(excludeFromClasspath = ['this-pattern-matches-no-real-jar-xyz'])
+    void excludeWithNoMatchPreservesChildClasspath() {
+        // End-to-end: the wiring runs the parent's classpath through the
+        // filter and lands on the child's -cp. With a pattern that matches
+        // nothing real, the child must still boot, find its classes, and
+        // run this assertion.
+        def cp = System.getProperty('java.class.path')
+        assertTrue(cp != null && !cp.isEmpty(), "child classpath was empty")
+        assertTrue(!cp.contains('this-pattern-matches-no-real-jar-xyz'),
+                "the test pattern should not appear in any real classpath 
entry")
+    }
+
     @Test
     @ExpectedToFail({ ex instanceof AssertionError && 
message.contains('expected failure from forked child') })
     @ForkedJvm

Reply via email to