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
