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


The following commit(s) were added to refs/heads/master by this push:
     new 16ba9168f2 GROOVY-11997: Add @ForkedJvm and @ExpectedToFail JUnit 
extensions to groovy-test-junit6
16ba9168f2 is described below

commit 16ba9168f23cbcd29c64fd66e535383295bfc82f
Author: Paul King <[email protected]>
AuthorDate: Wed May 6 01:35:33 2026 +1000

    GROOVY-11997: Add @ForkedJvm and @ExpectedToFail JUnit extensions to 
groovy-test-junit6
---
 build.gradle                                       |   1 +
 src/test/groovy/groovy/ValDisabledTest.groovy      |  44 ++---
 .../transform/stc/STCExtensionMethodsTest.groovy   |  55 +++---
 .../m12n/ExtensionModuleHelperForTests.groovy      | 123 --------------
 .../groovy/runtime/m12n/ExtensionModuleTest.groovy | 147 ++++++++--------
 subprojects/groovy-test-junit6/build.gradle        |   9 +
 .../java/groovy/junit6/plugin/ExpectedToFail.java  |  88 ++++++++++
 .../junit6/plugin/ExpectedToFailExtension.java     | 136 +++++++++++++++
 .../main/java/groovy/junit6/plugin/ForkedJvm.java  |  74 ++++++++
 .../groovy/junit6/plugin/ForkedJvmExtension.java   | 165 ++++++++++++++++++
 .../groovy/junit6/plugin/ForkedJvmTestRunner.java  | 155 +++++++++++++++++
 .../src/test/groovy/ExpectedToFailTest.groovy      | 186 +++++++++++++++++++++
 .../src/test/groovy/ForkedJvmTest.groovy           | 124 ++++++++++++++
 13 files changed, 1054 insertions(+), 253 deletions(-)

diff --git a/build.gradle b/build.gradle
index 91769c78cc..c4e6e7d851 100644
--- a/build.gradle
+++ b/build.gradle
@@ -125,6 +125,7 @@ dependencies {
     testImplementation projects.groovyTest
     testImplementation projects.groovyMacro
     testImplementation projects.groovyDateutil
+    testImplementation projects.groovyTestJunit6 // for 
groovy.junit6.plugin.ForkedJvm
     testImplementation "net.jcip:jcip-annotations:${versions.jcipAnnotations}"
     testImplementation "com.thoughtworks.qdox:qdox:${versions.qdox}"
     testImplementation 
"com.fasterxml.jackson.core:jackson-databind:${versions.jackson}"
diff --git a/src/test/groovy/groovy/ValDisabledTest.groovy 
b/src/test/groovy/groovy/ValDisabledTest.groovy
index 3eebe4aed7..bbaa586e46 100644
--- a/src/test/groovy/groovy/ValDisabledTest.groovy
+++ b/src/test/groovy/groovy/ValDisabledTest.groovy
@@ -18,32 +18,24 @@
  */
 package groovy
 
+import groovy.junit6.plugin.ForkedJvm
 import org.junit.jupiter.api.Test
 
-import static 
org.codehaus.groovy.runtime.m12n.ExtensionModuleHelperForTests.doInFork
+import static groovy.test.GroovyAssert.assertScript
 
 /**
  * Tests that when {@code groovy.val.enabled=false}, GEP-16 breaking
  * changes are resolved and {@code val} behaves as a regular identifier.
- *
- * Each test runs in a freshly forked JVM (compile + execution) with the
- * property set, so the lexer's {@code static final VAL_ENABLED} is
- * initialised to {@code false}.
+ * <p>
+ * Each test runs in a freshly forked JVM with the property set, so the
+ * lexer's {@code static final VAL_ENABLED} is initialised to {@code false}.
  */
+@ForkedJvm(systemProperties = ['groovy.val.enabled=false'])
 final class ValDisabledTest {
 
-    private static final List<String> JVM_ARGS = ['-Dgroovy.val.enabled=false']
-
-    private static void doInForkWithValDisabled(String script) {
-        // Wrap each snippet in assertScript so top-level class declarations 
work
-        // (the snippet is otherwise placed inside a method body where local
-        // classes are not supported).
-        doInFork('java.lang.Object', "assertScript '''${script.replace("'", 
"\\'")}'''", JVM_ARGS)
-    }
-
     @Test
     void testFieldNamedValBeforeMethod() {
-        doInForkWithValDisabled '''
+        assertScript '''
             class Foo {
                 def val
                 void doSomething() {}
@@ -56,16 +48,16 @@ final class ValDisabledTest {
 
     @Test
     void testValAsCastExpression() {
-        doInForkWithValDisabled '''
+        assertScript '''
             def val = 42
             def result = val as String
-            assert result == "42"
+            assert result == '42'
         '''
     }
 
     @Test
     void testClassNamedVal() {
-        doInForkWithValDisabled '''
+        assertScript '''
             class val {
                 int x
             }
@@ -76,7 +68,7 @@ final class ValDisabledTest {
 
     @Test
     void testValAsMethodReturnType() {
-        doInForkWithValDisabled '''
+        assertScript '''
             class val {
                 int x
             }
@@ -90,7 +82,7 @@ final class ValDisabledTest {
 
     @Test
     void testValAsExplicitType() {
-        doInForkWithValDisabled '''
+        assertScript '''
             class val {
                 int x
             }
@@ -101,7 +93,7 @@ final class ValDisabledTest {
 
     @Test
     void testDefValAssignment() {
-        doInForkWithValDisabled '''
+        assertScript '''
             def val = 1
             assert val == 1
         '''
@@ -109,7 +101,7 @@ final class ValDisabledTest {
 
     @Test
     void testValReassignment() {
-        doInForkWithValDisabled '''
+        assertScript '''
             def val = 1
             val = 2
             assert val == 2
@@ -118,7 +110,7 @@ final class ValDisabledTest {
 
     @Test
     void testValAsMapKey() {
-        doInForkWithValDisabled '''
+        assertScript '''
             def m = [val: 42]
             assert m.val == 42
         '''
@@ -126,10 +118,10 @@ final class ValDisabledTest {
 
     @Test
     void testValPropertyAccess() {
-        doInForkWithValDisabled '''
-            class Foo { def val = "hello" }
+        assertScript '''
+            class Foo { def val = 'hello' }
             def f = new Foo()
-            assert f.val == "hello"
+            assert f.val == 'hello'
         '''
     }
 }
diff --git 
a/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy 
b/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy
index d363978114..ea629a1842 100644
--- a/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy
+++ b/src/test/groovy/groovy/transform/stc/STCExtensionMethodsTest.groovy
@@ -18,10 +18,10 @@
  */
 package groovy.transform.stc
 
+import groovy.junit6.plugin.ForkedJvm
+import org.codehaus.groovy.runtime.m12n.ExtensionModuleRegistry
 import org.junit.jupiter.api.Test
 
-import static 
org.codehaus.groovy.runtime.m12n.ExtensionModuleHelperForTests.doInFork
-
 /**
  * Unit tests for static type checking : extension methods.
  */
@@ -53,33 +53,32 @@ class STCExtensionMethodsTest extends 
StaticTypeCheckingTestCase {
      * @see org.codehaus.groovy.runtime.m12n.TestStaticStringExtension
      */
     @Test
+    @ForkedJvm
     void testShouldFindExtensionMethodWithGrab() {
-        doInFork 'groovy.transform.stc.StaticTypeCheckingTestCase', '''
-            def impl = new MetaClassImpl(String)
-            impl.initialize()
-            String.metaClass = impl
-            try {
-                ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
-                // ensure that the module isn't loaded
-                assert !registry.modules.any { it.name == 'Test module for 
Grab' && it.version == '1.4' }
-
-                def jarURL = this.class.getResource('/jars')
-                assert jarURL
-
-                assertScript """@GrabResolver(name='local',root='$jarURL')
-                    @Grab('module-test:module-test:1.4;changing=true')
-                    import org.codehaus.groovy.runtime.m12n.*
-
-                    // the following methods are added by the Grab test module
-                    def str = 'This is a string'
-                    assert str.reverseToUpperCase2() == 
str.toUpperCase().reverse()
-                    // a static method added to String thanks to a @Grab 
extension
-                    assert String.answer2() == 42
-                """
-            } finally {
-                String.metaClass = null
-            }
-        '''
+        def impl = new MetaClassImpl(String)
+        impl.initialize()
+        String.metaClass = impl
+        try {
+            ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
+            // ensure that the module isn't loaded
+            assert !registry.modules.any { it.name == 'Test module for Grab' 
&& it.version == '1.4' }
+
+            def jarURL = this.class.getResource('/jars')
+            assert jarURL
+
+            assertScript """@GrabResolver(name='local',root='$jarURL')
+                @Grab('module-test:module-test:1.4;changing=true')
+                import org.codehaus.groovy.runtime.m12n.*
+
+                // the following methods are added by the Grab test module
+                def str = 'This is a string'
+                assert str.reverseToUpperCase2() == str.toUpperCase().reverse()
+                // a static method added to String thanks to a @Grab extension
+                assert String.answer2() == 42
+            """
+        } finally {
+            String.metaClass = null
+        }
     }
 
     /**
diff --git 
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleHelperForTests.groovy
 
b/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleHelperForTests.groovy
deleted file mode 100644
index bd36f05855..0000000000
--- 
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleHelperForTests.groovy
+++ /dev/null
@@ -1,123 +0,0 @@
-/*
- *  Licensed to the Apache Software Foundation (ASF) under one
- *  or more contributor license agreements.  See the NOTICE file
- *  distributed with this work for additional information
- *  regarding copyright ownership.  The ASF licenses this file
- *  to you under the Apache License, Version 2.0 (the
- *  "License"); you may not use this file except in compliance
- *  with the License.  You may obtain a copy of the License at
- *
- *    http://www.apache.org/licenses/LICENSE-2.0
- *
- *  Unless required by applicable law or agreed to in writing,
- *  software distributed under the License is distributed on an
- *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- *  KIND, either express or implied.  See the License for the
- *  specific language governing permissions and limitations
- *  under the License.
- */
-package org.codehaus.groovy.runtime.m12n
-
-import groovy.ant.AntBuilder
-
-final class ExtensionModuleHelperForTests {
-
-    private ExtensionModuleHelperForTests() {}
-
-    static void doInFork(String baseTestClass = 'java.lang.Object', String 
code) {
-        doInFork(baseTestClass, code, Collections.<String>emptyList())
-    }
-
-    static void doInFork(String baseTestClass, String code, List<String> 
extraJvmArgs) {
-        File baseDir = File.createTempDir()
-        File sourceFile = new File(baseDir, 'Temp.groovy')
-        sourceFile << """import org.codehaus.groovy.runtime.m12n.*
-            import static groovy.test.GroovyAssert.assertScript
-            class TempTest extends $baseTestClass {
-                @org.junit.jupiter.api.Test
-                void testCode() {
-                    $code
-                }
-            }
-
-            import org.junit.platform.launcher.core.*
-            import 
org.junit.platform.launcher.listeners.SummaryGeneratingListener
-            import static 
org.junit.platform.engine.discovery.DiscoverySelectors.*
-
-            def launcher = LauncherFactory.create()
-            def listener = new SummaryGeneratingListener()
-            launcher.registerTestExecutionListeners(listener)
-            def testPlan = launcher.discover(
-                LauncherDiscoveryRequestBuilder.request().selectors(
-                    selectClass("TempTest")
-                ).build()
-            )
-            launcher.execute(testPlan)
-            // JUnit Platform's launcher is silent on failure; surface 
failures via
-            // stderr so the parent process (doInFork) detects them as stray 
lines.
-            def summary = listener.summary
-            if (summary.totalFailureCount) {
-                summary.failures.each { f ->
-                    System.err.println('TEST FAILED: ' + 
f.testIdentifier.displayName + ' :: ' + f.exception)
-                    f.exception.printStackTrace(System.err)
-                }
-            }
-        """
-
-        Set<String> cp = 
System.getProperty('java.class.path').split(File.pathSeparator) as Set
-        cp << baseDir.absolutePath
-
-        def ant = new AntBuilder()
-        def allowed = [
-            ~/Picked up JAVA_TOOL_OPTIONS: .*/,
-            ~/Picked up _JAVA_OPTIONS: .*/
-        ]
-        def jvmArgs = []
-        jvmArgs.addAll(extraJvmArgs)
-        if (Runtime.version().feature() == 25) {
-            // JEP 471/498: silence terminal-deprecation warnings for 
sun.misc.Unsafe
-            // memory-access methods called from agents on the inherited 
classpath
-            // (e.g. testlens's shaded protobuf UnsafeUtil::arrayBaseOffset).
-            // remove when we can - this could mask errors we want to pick up
-            jvmArgs << '--sun-misc-unsafe-memory-access=allow'
-        }
-        try {
-            ant.with {
-                // Compile via FileSystemCompilerFacade in a forked JVM (same 
as forked groovyc),
-                // but using ant.java so we can attach arbitrary JVM args 
(e.g. system properties).
-                java(classname: 
'org.codehaus.groovy.ant.FileSystemCompilerFacade', fork: 'true', failonerror: 
'true') {
-                    jvmArgs.each { jvmarg(value: it) }
-                    classpath {
-                        cp.each { pathelement location: it }
-                    }
-                    arg(value: '--classpath')
-                    arg(value: cp.join(File.pathSeparator))
-                    arg(value: '-d')
-                    arg(value: baseDir.absolutePath)
-                    arg(value: sourceFile.absolutePath)
-                }
-                java(classname: 'Temp', fork: 'true', outputproperty: 'out', 
errorproperty: 'err') {
-                    jvmArgs.each { jvmarg(value: it) }
-                    classpath {
-                        cp.each {
-                            pathelement location: it
-                        }
-                    }
-                }
-            }
-        } finally {
-            baseDir.deleteDir()
-            String out = ant.project.properties.out
-            String err = ant.project.properties.err
-            def stray = err?.readLines()?.findAll { line ->
-                line.trim() && !allowed.any { line ==~ it }
-            } ?: []
-            if (stray) {
-                throw new RuntimeException("${stray.join('\n')}\nClasspath: 
${cp.join('\n')}")
-            }
-            if (out && (out.contains('FAILURES') || !out.contains('OK'))) {
-                throw new RuntimeException("$out\nClasspath: ${cp.join('\n')}")
-            }
-        }
-    }
-}
diff --git 
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy 
b/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy
index 3bba1357b3..964d76270b 100644
--- 
a/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy
+++ 
b/src/test/groovy/org/codehaus/groovy/runtime/m12n/ExtensionModuleTest.groovy
@@ -18,107 +18,104 @@
  */
 package org.codehaus.groovy.runtime.m12n
 
+import groovy.junit6.plugin.ForkedJvm
 import org.junit.jupiter.api.Test
 
-import static 
org.codehaus.groovy.runtime.m12n.ExtensionModuleHelperForTests.doInFork
+import static groovy.test.GroovyAssert.assertScript
 
 /**
  * Unit tests for extension methods loading.
+ * <p>
+ * Each test runs in a freshly forked JVM so its MetaClass-registry mutations
+ * (e.g. modules loaded via {@code @Grab}) don't pollute the main test JVM.
  */
+@ForkedJvm
 final class ExtensionModuleTest {
 
     @Test
     void testThatModuleHasBeenLoaded() {
-        doInFork '''
-            ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
-            assert registry.modules
-            // look for the 'Test module' module; it should always be available
-            assert registry.modules.any { it.name == 'Test module' && 
it.version == '1.0-test' }
-
-            // the following methods are added by the test module
-            def str = 'This is a string'
-            assert str.reverseToUpperCase() == str.toUpperCase().reverse()
-            assert String.answer() == 42
-        '''
+        ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
+        assert registry.modules
+        // look for the 'Test module' module; it should always be available
+        assert registry.modules.any { it.name == 'Test module' && it.version 
== '1.0-test' }
+
+        // the following methods are added by the test module
+        def str = 'This is a string'
+        assert str.reverseToUpperCase() == str.toUpperCase().reverse()
+        assert String.answer() == 42
     }
 
     @Test
     void testThatModuleCanBeLoadedWithGrab() {
-        doInFork '''
-            ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
-            // ensure that the module isn't loaded
-            assert !registry.modules.any { it.name == 'Test module for Grab' 
&& it.version == '1.4' }
+        ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
+        // ensure that the module isn't loaded
+        assert !registry.modules.any { it.name == 'Test module for Grab' && 
it.version == '1.4' }
 
-            // find jar resource
-            def jarURL = this.class.getResource("/jars")
-            assert jarURL
+        // find jar resource
+        def jarURL = this.class.getResource("/jars")
+        assert jarURL
 
-            def resolver = "@GrabResolver('$jarURL')"
+        def resolver = "@GrabResolver('$jarURL')"
 
-            assertScript resolver + """
-                @Grab(value='module-test:module-test:1.4', changing=true)
-                import org.codehaus.groovy.runtime.m12n.*
+        assertScript resolver + """
+            @Grab(value='module-test:module-test:1.4', changing=true)
+            import org.codehaus.groovy.runtime.m12n.*
 
-                // ensure that the module is now loaded
-                ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
-                assert registry.modules.any { it.name == 'Test module for 
Grab' && it.version == '1.4' }
+            // ensure that the module is now loaded
+            ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
+            assert registry.modules.any { it.name == 'Test module for Grab' && 
it.version == '1.4' }
 
-                // the following methods are added by the 'Test module for 
Grab' module
-                def str = 'This is a string'
-                assert str.reverseToUpperCase2() == str.toUpperCase().reverse()
-                assert String.answer2() == 42
-            """
+            // the following methods are added by the 'Test module for Grab' 
module
+            def str = 'This is a string'
+            assert str.reverseToUpperCase2() == str.toUpperCase().reverse()
+            assert String.answer2() == 42
+        """
 
-            // the module should still be available
-            assert registry.modules.any { it.name == 'Test module for Grab' && 
it.version == '1.4' }
-        '''
+        // the module should still be available
+        assert registry.modules.any { it.name == 'Test module for Grab' && 
it.version == '1.4' }
     }
 
     @Test
     void testExtensionModuleUsingGrabAndMap() {
-        doInFork '''
-            ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
-            // ensure that the module isn't loaded
-            assert !registry.modules.any { it.name == 'Test module for Grab' 
&& it.version == '1.4' }
+        ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
+        // ensure that the module isn't loaded
+        assert !registry.modules.any { it.name == 'Test module for Grab' && 
it.version == '1.4' }
 
-            // find jar resource
-            def jarURL = this.class.getResource("/jars")
-            assert jarURL
+        // find jar resource
+        def jarURL = this.class.getResource("/jars")
+        assert jarURL
 
-            def resolver = "@GrabResolver('$jarURL')"
+        def resolver = "@GrabResolver('$jarURL')"
 
-            assertScript resolver + """
-                @Grab(value='module-test:module-test:1.4', changing=true)
-                import org.codehaus.groovy.runtime.m12n.*
+        assertScript resolver + """
+            @Grab(value='module-test:module-test:1.4', changing=true)
+            import org.codehaus.groovy.runtime.m12n.*
 
-                def map = [:]
-                assert 'foo'.taille() == 3
-                assert map.taille() == 0
-            """
-        '''
+            def map = [:]
+            assert 'foo'.taille() == 3
+            assert map.taille() == 0
+        """
     }
 
     // GROOVY-7225
     @Test
     void testExtensionModuleUsingGrabAndClosure() {
-        doInFork '''
-            ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
-            // ensure that the module isn't loaded
-            assert !registry.modules.any { it.name == 'Test module for Grab' 
&& it.version == '1.4' }
-
-            // find jar resource
-            def jarURL = this.class.getResource("/jars")
-            assert jarURL
-
-            assertScript """
-                @GrabResolver('$jarURL')
-                @Grab(value='module-test:module-test:1.4', changing=true)
-                import org.codehaus.groovy.runtime.m12n.*
-
-                assert 'test'.groovy7225() == 'test: ok'
-                assert {->}.groovy7225() == '{"field":"value"}'
-            """
-        '''
+        ExtensionModuleRegistry registry = 
GroovySystem.metaClassRegistry.moduleRegistry
+        // ensure that the module isn't loaded
+        assert !registry.modules.any { it.name == 'Test module for Grab' && 
it.version == '1.4' }
+
+        // find jar resource
+        def jarURL = this.class.getResource("/jars")
+        assert jarURL
+
+        assertScript """
+            @GrabResolver('$jarURL')
+            @Grab(value='module-test:module-test:1.4', changing=true)
+            import org.codehaus.groovy.runtime.m12n.*
+
+            assert 'test'.groovy7225() == 'test: ok'
+            assert {->}.groovy7225() == '{"field":"value"}'
+        """
     }
 
     /**
@@ -128,13 +125,11 @@ final class ExtensionModuleTest {
      */
     @Test
     void testOverrideLocalDateTimeCompareTo() {
-        doInFork '''
-            def d1 = java.time.LocalDateTime.now()
-            def d2 = java.time.LocalDate.now().plusDays(42)
-            def d3 = java.time.LocalDate.now().minusDays(42)
-
-            assert d1 < d2
-            assert d1 > d3
-        '''
+        def d1 = java.time.LocalDateTime.now()
+        def d2 = java.time.LocalDate.now().plusDays(42)
+        def d3 = java.time.LocalDate.now().minusDays(42)
+
+        assert d1 < d2
+        assert d1 > d3
     }
 }
diff --git a/subprojects/groovy-test-junit6/build.gradle 
b/subprojects/groovy-test-junit6/build.gradle
index aa33a60d19..36b35ede8e 100644
--- a/subprojects/groovy-test-junit6/build.gradle
+++ b/subprojects/groovy-test-junit6/build.gradle
@@ -37,3 +37,12 @@ dependencies {
     testImplementation projects.groovyTest
     testImplementation 
"org.junit.jupiter:junit-jupiter-params:${versions.junit6}"
 }
+
+tasks.named('test') {
+    useJUnitPlatform {
+        // Fixture classes for negative-path tests are tagged 'manual' and
+        // invoked explicitly via the JUnit Platform Launcher; keep them out
+        // of normal discovery so their deliberate failures don't fail CI.
+        excludeTags 'manual'
+    }
+}
diff --git 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFail.java
 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFail.java
new file mode 100644
index 0000000000..69f11722b6
--- /dev/null
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFail.java
@@ -0,0 +1,88 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.apache.groovy.lang.annotation.Incubating;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Inverts the pass/fail outcome of the annotated test method (or every test
+ * method on the annotated class): a test that throws is reported as passing,
+ * and a test that completes normally is reported as failing.
+ * <p>
+ * Useful for asserting that some condition reliably fails, and for testing
+ * test-infrastructure code that should propagate failures.
+ * <p>
+ * Optionally constrain the expected failure by exception type and/or message
+ * substring:
+ * <pre>
+ * &#64;Test
+ * &#64;ExpectedToFail(IllegalStateException.class)
+ * void mustThrowIse() { throw new IllegalStateException("boom") }
+ *
+ * &#64;Test
+ * &#64;ExpectedToFail(messageContains = "boom")
+ * void mustThrowWithBoomInMessage() { throw new RuntimeException("kaboom!") }
+ * </pre>
+ * <p>
+ * {@code TestAbortedException} (the exception thrown by JUnit
+ * {@code Assumptions}) is never treated as the expected failure; it's
+ * always rethrown so the test is reported as aborted.
+ * <p>
+ * <b>Composition with {@link ForkedJvm}:</b> works in either declaration
+ * order via explicit coordination between the two extensions (no reliance on
+ * annotation iteration order). When {@code @ExpectedToFail} is declared
+ * <em>before</em> {@code @ForkedJvm} (i.e., outer), the inversion happens in
+ * the parent JVM after the failure propagates from the fork — exercising
+ * {@code @ForkedJvm}'s serialisation. When declared after (inner), the
+ * inversion happens inside the forked child.
+ *
+ * @since 6.0.0
+ */
+@Documented
+@Incubating
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+@ExtendWith(ExpectedToFailExtension.class)
+public @interface ExpectedToFail {
+    /**
+     * Expected exception type. The thrown exception must be an instance of
+     * this class (or a subclass) for the test to be reported as passing.
+     * Defaults to {@link Throwable}, which matches anything.
+     */
+    Class<? extends Throwable> value() default Throwable.class;
+
+    /**
+     * Substring that must appear in the thrown exception's message.
+     * Defaults to empty (no message check).
+     */
+    String messageContains() default "";
+
+    /**
+     * Optional human-readable explanation of why this test is expected to 
fail.
+     * Surfaced in the failure message when the test unexpectedly passes.
+     */
+    String reason() default "";
+}
diff --git 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailExtension.java
 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailExtension.java
new file mode 100644
index 0000000000..23e84166e4
--- /dev/null
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ExpectedToFailExtension.java
@@ -0,0 +1,136 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.opentest4j.TestAbortedException;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+/**
+ * JUnit 5 {@link InvocationInterceptor} backing the {@link ExpectedToFail}
+ * annotation. Inverts a test's pass/fail outcome with optional exception-type
+ * and message-substring filters.
+ * <p>
+ * Composes with {@link ForkedJvm} in either declaration order via explicit
+ * coordination through {@link ExtensionContext.Store} and a child-JVM system
+ * property &mdash; not via brittle reliance on annotation iteration order.
+ *
+ * @since 6.0.0
+ */
+public class ExpectedToFailExtension implements InvocationInterceptor {
+
+    /**
+     * Shared {@link ExtensionContext.Namespace} used to coordinate inversion
+     * placement between this extension and {@link ForkedJvmExtension}.
+     */
+    static final ExtensionContext.Namespace NAMESPACE =
+            ExtensionContext.Namespace.create("groovy.junit6.expectedtofail");
+
+    /**
+     * Store key set by the parent's {@code @ExpectedToFail} interceptor when
+     * it intends to invert the outcome itself (i.e. it's the OUTER
+     * interceptor relative to {@code @ForkedJvm}). {@link ForkedJvmExtension}
+     * reads this key and, if present, instructs the forked child to defer
+     * inversion via {@link #DEFERRED_TO_PARENT_PROP}.
+     */
+    static final String STORE_KEY_PARENT_INVERTING = "parentInverting";
+
+    /**
+     * System property set on the child JVM's command line by
+     * {@link ForkedJvmExtension} when the parent's {@code @ExpectedToFail}
+     * has claimed the inversion. The child's {@code @ExpectedToFail}
+     * interceptor checks this property and passes through honestly so the
+     * parent can observe the propagated outcome.
+     */
+    public static final String DEFERRED_TO_PARENT_PROP = 
"groovy.junit6.expectedtofail.deferred";
+
+    @Override
+    public void interceptTestMethod(Invocation<Void> invocation,
+                                    ReflectiveInvocationContext<Method> 
invocationContext,
+                                    ExtensionContext extensionContext) throws 
Throwable {
+        ExpectedToFail config = findAnnotation(extensionContext);
+        if (config == null) {
+            invocation.proceed();
+            return;
+        }
+        Method method = invocationContext.getExecutable();
+
+        // If this is the child JVM and the parent claimed inversion, just pass
+        // through; the parent's interceptor will observe the propagated 
outcome.
+        if (Boolean.parseBoolean(System.getProperty(DEFERRED_TO_PARENT_PROP))) 
{
+            invocation.proceed();
+            return;
+        }
+
+        // Otherwise this layer handles inversion. If we're in the parent and
+        // @ForkedJvm is also present, signal the fork (via the shared store)
+        // so the child's interceptor knows to defer to us.
+        boolean inForkedJvm = 
Boolean.parseBoolean(System.getProperty(ForkedJvmTestRunner.FORKED_FLAG));
+        if (!inForkedJvm && annotationOnMethodOrClass(method, 
ForkedJvm.class)) {
+            
extensionContext.getStore(NAMESPACE).put(STORE_KEY_PARENT_INVERTING, 
Boolean.TRUE);
+        }
+
+        Throwable thrown = null;
+        try {
+            invocation.proceed();
+        } catch (TestAbortedException tae) {
+            // Assumption failures never count as the expected failure.
+            throw tae;
+        } catch (Throwable t) {
+            thrown = t;
+        }
+        evaluateOutcome(thrown, config);
+    }
+
+    private static boolean annotationOnMethodOrClass(Method method, Class<? 
extends Annotation> a) {
+        return method.isAnnotationPresent(a) || 
method.getDeclaringClass().isAnnotationPresent(a);
+    }
+
+    private static void evaluateOutcome(Throwable thrown, ExpectedToFail 
config) {
+        if (thrown != null) {
+            if (!config.value().isInstance(thrown)) {
+                throw new AssertionError("@ExpectedToFail expected exception 
of type "
+                        + config.value().getName() + " but got "
+                        + thrown.getClass().getName() + ": " + 
thrown.getMessage(), thrown);
+            }
+            String wanted = config.messageContains();
+            if (!wanted.isEmpty()
+                    && (thrown.getMessage() == null || 
!thrown.getMessage().contains(wanted))) {
+                throw new AssertionError("@ExpectedToFail expected message 
containing '"
+                        + wanted + "' but got: " + thrown.getMessage(), 
thrown);
+            }
+            return; // matched: swallow, treat as success
+        }
+        String reason = config.reason();
+        throw new AssertionError("@ExpectedToFail: test was expected to fail 
but passed"
+                + (reason.isEmpty() ? "" : " (" + reason + ")"));
+    }
+
+    private static ExpectedToFail findAnnotation(ExtensionContext context) {
+        return context.getElement()
+                .map(el -> el.getAnnotation(ExpectedToFail.class))
+                .orElseGet(() -> context.getTestClass()
+                        .map(c -> c.getAnnotation(ExpectedToFail.class))
+                        .orElse(null));
+    }
+}
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
new file mode 100644
index 0000000000..80138b3cbb
--- /dev/null
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvm.java
@@ -0,0 +1,74 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.apache.groovy.lang.annotation.Incubating;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Runs the annotated test method (or every test method on the annotated class)
+ * in a freshly forked JVM, optionally configured with extra system properties
+ * and JVM arguments.
+ * <p>
+ * Useful for testing behaviour gated by JVM startup state that cannot be
+ * toggled at runtime, e.g. {@code static final} fields initialised from
+ * {@code System.getProperty(...)} at class load time
+ * (such as {@code groovy.val.enabled}).
+ * <p>
+ * System properties are supplied as {@code "key=value"} strings:
+ * <pre>
+ * &#64;Test
+ * &#64;ForkedJvm(systemProperties = {"groovy.val.enabled=false"})
+ * void runsInChildJvm() {
+ *     assert System.getProperty("groovy.val.enabled").equals("false");
+ * }
+ * </pre>
+ * <p>
+ * JVM arguments are passed verbatim:
+ * <pre>
+ * &#64;Test
+ * &#64;ForkedJvm(jvmArgs = {"--add-opens=java.base/java.lang=ALL-UNNAMED"})
+ * void withModuleAccess() { ... }
+ * </pre>
+ *
+ * @since 6.0.0
+ */
+@Documented
+@Incubating
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+@ExtendWith(ForkedJvmExtension.class)
+public @interface ForkedJvm {
+    /**
+     * System properties to set on the forked JVM, each as a {@code 
"key=value"} string.
+     */
+    String[] systemProperties() default {};
+
+    /**
+     * Raw JVM arguments to prepend to the forked JVM's command line, e.g.
+     * {@code "-Xmx128m"} or {@code 
"--add-opens=java.base/java.lang=ALL-UNNAMED"}.
+     */
+    String[] jvmArgs() 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
new file mode 100644
index 0000000000..197305d49e
--- /dev/null
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmExtension.java
@@ -0,0 +1,165 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.InvocationInterceptor;
+import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
+import org.opentest4j.TestAbortedException;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * JUnit 5 {@link InvocationInterceptor} backing the {@link ForkedJvm}
+ * annotation. When applied, the test method is skipped in the current JVM and
+ * re-run in a freshly forked JVM via {@link ForkedJvmTestRunner}, with any
+ * declared system properties and JVM args.
+ * <p>
+ * Recursion is avoided by setting the system property
+ * {@link ForkedJvmTestRunner#FORKED_FLAG} on the child; when the extension
+ * sees that flag set in the current JVM it just proceeds with the normal
+ * invocation (i.e. the child JVM actually runs the test body).
+ *
+ * @since 6.0.0
+ */
+public class ForkedJvmExtension implements InvocationInterceptor {
+
+    @Override
+    public void interceptTestMethod(Invocation<Void> invocation,
+                                    ReflectiveInvocationContext<Method> 
invocationContext,
+                                    ExtensionContext extensionContext) throws 
Throwable {
+        if 
(Boolean.parseBoolean(System.getProperty(ForkedJvmTestRunner.FORKED_FLAG))) {
+            // Already in the child JVM — run the body for real.
+            invocation.proceed();
+            return;
+        }
+
+        ForkedJvm config = findAnnotation(extensionContext);
+        if (config == null) {
+            invocation.proceed();
+            return;
+        }
+
+        // Skip in this (parent) JVM; we'll run the same method in a child.
+        invocation.skip();
+
+        Class<?> testClass = invocationContext.getTargetClass();
+        Method testMethod = invocationContext.getExecutable();
+        boolean parentInverting = Boolean.TRUE.equals(extensionContext
+                .getStore(ExpectedToFailExtension.NAMESPACE)
+                .get(ExpectedToFailExtension.STORE_KEY_PARENT_INVERTING));
+        runInForkedJvm(testClass, testMethod, config, parentInverting);
+    }
+
+    private static ForkedJvm findAnnotation(ExtensionContext context) {
+        return context.getElement()
+                .map(el -> el.getAnnotation(ForkedJvm.class))
+                .orElseGet(() -> context.getTestClass()
+                        .map(c -> c.getAnnotation(ForkedJvm.class))
+                        .orElse(null));
+    }
+
+    private static void runInForkedJvm(Class<?> testClass, Method testMethod,
+                                       ForkedJvm config, boolean 
parentInverting) throws Throwable {
+        Path resultFile = Files.createTempFile("groovy-forked-jvm-result", 
".bin");
+        try {
+            List<String> command = buildCommand(testClass, testMethod, config, 
resultFile, parentInverting);
+            ProcessBuilder pb = new ProcessBuilder(command).inheritIO();
+            int exit = pb.start().waitFor();
+            propagateOutcome(exit, resultFile, testClass, testMethod);
+        } finally {
+            try { Files.deleteIfExists(resultFile); } catch (IOException 
ignore) {}
+        }
+    }
+
+    private static List<String> buildCommand(Class<?> testClass, Method 
testMethod,
+                                             ForkedJvm config, Path resultFile,
+                                             boolean parentInverting) {
+        List<String> cmd = new ArrayList<>();
+        String javaHome = System.getProperty("java.home");
+        String javaExe = System.getProperty("os.name", 
"").startsWith("Windows") ? "java.exe" : "java";
+        cmd.add(javaHome + File.separator + "bin" + File.separator + javaExe);
+        cmd.add("-D" + ForkedJvmTestRunner.FORKED_FLAG + "=true");
+        cmd.add("-D" + ForkedJvmTestRunner.RESULT_FILE_PROP + "=" + 
resultFile);
+        if (parentInverting) {
+            cmd.add("-D" + ExpectedToFailExtension.DEFERRED_TO_PARENT_PROP + 
"=true");
+        }
+        for (String sp : config.systemProperties()) {
+            int eq = sp.indexOf('=');
+            if (eq < 0) {
+                throw new IllegalArgumentException(
+                        "@ForkedJvm system property must be 'key=value', got: 
" + sp);
+            }
+            cmd.add("-D" + sp);
+        }
+        for (String arg : config.jvmArgs()) {
+            cmd.add(arg);
+        }
+        cmd.add("-cp");
+        cmd.add(System.getProperty("java.class.path"));
+        cmd.add(ForkedJvmTestRunner.class.getName());
+        cmd.add(testClass.getName());
+        cmd.add(testMethod.getName());
+        return cmd;
+    }
+
+    private static void propagateOutcome(int exit, Path resultFile,
+                                         Class<?> testClass, Method 
testMethod) throws Throwable {
+        String location = testClass.getName() + "#" + testMethod.getName();
+        byte[] bytes;
+        try {
+            bytes = Files.readAllBytes(resultFile);
+        } catch (IOException ioe) {
+            throw new AssertionError("Forked JVM for " + location
+                    + " exited with code " + exit
+                    + " and the result file at " + resultFile + " was 
unreadable", ioe);
+        }
+        if (exit == 0 && bytes.length == 0) return;
+
+        if (bytes.length == 0) {
+            throw new AssertionError("Forked JVM for " + location
+                    + " exited with code " + exit + " (no failure detail 
captured)");
+        }
+        if (bytes[0] == ForkedJvmTestRunner.ABORTED_MARKER) {
+            String reason = new String(bytes, 1, bytes.length - 1, 
StandardCharsets.UTF_8);
+            throw new TestAbortedException("Forked JVM for " + location + " 
aborted: " + reason);
+        }
+        if (bytes[0] == ForkedJvmTestRunner.TEXT_FALLBACK_MARKER) {
+            throw new AssertionError("Forked JVM for " + location + " 
failed:\n"
+                    + new String(bytes, 1, bytes.length - 1, 
StandardCharsets.UTF_8));
+        }
+        try (ObjectInputStream ois = new ObjectInputStream(new 
ByteArrayInputStream(bytes))) {
+            Throwable t = (Throwable) ois.readObject();
+            // Rethrow exactly so JUnit reports the original failure 
type/message.
+            throw t;
+        } catch (ClassNotFoundException | IOException deserFailed) {
+            throw new AssertionError("Forked JVM for " + location
+                    + " failed and result couldn't be deserialised", 
deserFailed);
+        }
+    }
+}
diff --git 
a/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmTestRunner.java
 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmTestRunner.java
new file mode 100644
index 0000000000..0b4bc7315b
--- /dev/null
+++ 
b/subprojects/groovy-test-junit6/src/main/java/groovy/junit6/plugin/ForkedJvmTestRunner.java
@@ -0,0 +1,155 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package groovy.junit6.plugin;
+
+import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.launcher.Launcher;
+import org.junit.platform.launcher.LauncherDiscoveryRequest;
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
+import org.junit.platform.launcher.core.LauncherFactory;
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
+import org.junit.platform.launcher.listeners.TestExecutionSummary;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Child-JVM entry point for {@link ForkedJvm}-annotated tests.
+ * <p>
+ * Invoked by {@link ForkedJvmExtension} with the qualified test class name
+ * and method name as command-line arguments. Runs exactly that one test method
+ * via the JUnit Platform {@link Launcher}, then reports outcome to the parent
+ * via the file referenced by the system property
+ * {@code groovy.junit6.forked.result}: empty file on success, serialised
+ * {@link Throwable} (with text fallback) on failure.
+ *
+ * @since 6.0.0
+ */
+public final class ForkedJvmTestRunner {
+
+    /** Set on the child JVM so {@link ForkedJvmExtension} doesn't re-fork. */
+    public static final String FORKED_FLAG = "groovy.junit6.forked";
+
+    /** Path of the result file the child writes into. */
+    public static final String RESULT_FILE_PROP = 
"groovy.junit6.forked.result";
+
+    /**
+     * Marker byte at the start of the result file when the failure had to be
+     * written as text instead of a serialised {@link Throwable}.
+     * Distinguishable because {@link ObjectOutputStream}'s STREAM_MAGIC is
+     * {@code 0xACED}, never starts with {@code 0x00}.
+     */
+    public static final byte TEXT_FALLBACK_MARKER = 0;
+
+    /** Marker byte at the start of the result file for an aborted test. */
+    public static final byte ABORTED_MARKER = 1;
+
+    /** Exit code used when the child reports an aborted test. */
+    public static final int ABORTED_EXIT_CODE = 4;
+
+    private ForkedJvmTestRunner() {}
+
+    public static void main(String[] args) throws Exception {
+        if (args.length != 2) {
+            System.err.println("Usage: ForkedJvmTestRunner <testClass> 
<methodName>");
+            System.exit(2);
+        }
+        String className = args[0];
+        String methodName = args[1];
+        String resultFilePath = System.getProperty(RESULT_FILE_PROP);
+        if (resultFilePath == null) {
+            System.err.println("ForkedJvmTestRunner: required system property "
+                    + RESULT_FILE_PROP + " is not set");
+            System.exit(2);
+        }
+        Path resultPath = Paths.get(resultFilePath);
+
+        Class<?> testClass = Class.forName(className);
+        // Resolve a single overload by parameter-less name; ForkedJvm tests
+        // shouldn't have parameterised method signatures we can't match.
+        LauncherDiscoveryRequest req = 
LauncherDiscoveryRequestBuilder.request()
+                .selectors(DiscoverySelectors.selectMethod(testClass, 
methodName))
+                .build();
+
+        Launcher launcher = LauncherFactory.create();
+        SummaryGeneratingListener listener = new SummaryGeneratingListener();
+        launcher.registerTestExecutionListeners(listener);
+        launcher.execute(req);
+
+        TestExecutionSummary summary = listener.getSummary();
+        if (summary.getTestsFoundCount() == 0) {
+            writeTextFallback(resultPath,
+                    "ForkedJvmTestRunner: no test discovered for "
+                            + className + "#" + methodName);
+            System.exit(3);
+        }
+        if (summary.getTotalFailureCount() == 0 && 
summary.getTestsAbortedCount() > 0) {
+            // Test was aborted (e.g. via Assumptions); propagate as abort, 
not as success.
+            String reason = "test aborted";
+            writeAborted(resultPath, reason);
+            System.exit(ABORTED_EXIT_CODE);
+        }
+        if (summary.getTotalFailureCount() == 0) {
+            Files.write(resultPath, new byte[0]);
+            System.exit(0);
+        }
+        Throwable cause = summary.getFailures().get(0).getException();
+        writeFailure(resultPath, cause);
+        System.exit(1);
+    }
+
+    private static void writeAborted(Path path, String reason) throws 
IOException {
+        try (OutputStream out = Files.newOutputStream(path)) {
+            out.write(ABORTED_MARKER);
+            
out.write(reason.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+        }
+    }
+
+    private static void writeFailure(Path path, Throwable t) throws 
IOException {
+        try (ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+             ObjectOutputStream oos = new ObjectOutputStream(bytes)) {
+            oos.writeObject(t);
+            oos.flush();
+            Files.write(path, bytes.toByteArray());
+        } catch (Exception serializationFailed) {
+            // Some test exceptions aren't Serializable; fall back to text.
+            writeTextFallback(path, stackTraceText(t));
+        }
+    }
+
+    private static void writeTextFallback(Path path, String text) throws 
IOException {
+        try (OutputStream out = Files.newOutputStream(path)) {
+            out.write(TEXT_FALLBACK_MARKER);
+            out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+        }
+    }
+
+    private static String stackTraceText(Throwable t) {
+        StringWriter sw = new StringWriter();
+        t.printStackTrace(new PrintWriter(sw));
+        return sw.toString();
+    }
+}
diff --git 
a/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy 
b/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
new file mode 100644
index 0000000000..9a11f60304
--- /dev/null
+++ b/subprojects/groovy-test-junit6/src/test/groovy/ExpectedToFailTest.groovy
@@ -0,0 +1,186 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+import groovy.junit6.plugin.ExpectedToFail
+import groovy.junit6.plugin.ForkedJvm
+import org.junit.jupiter.api.Assumptions
+import org.junit.jupiter.api.Tag
+import org.junit.jupiter.api.Test
+import org.junit.platform.engine.discovery.DiscoverySelectors
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
+import org.junit.platform.launcher.core.LauncherFactory
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener
+import org.junit.platform.launcher.listeners.TestExecutionSummary
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNotNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+class ExpectedToFailTest {
+
+    // ---------------- happy paths (the body throws as expected) 
----------------
+
+    @Test
+    @ExpectedToFail
+    void throwingBodyIsTreatedAsSuccess() {
+        throw new AssertionError('expected boom')
+    }
+
+    @Test
+    @ExpectedToFail(IllegalStateException)
+    void typeFilterMatchesExactType() {
+        throw new IllegalStateException('boom')
+    }
+
+    @Test
+    @ExpectedToFail(RuntimeException)
+    void typeFilterMatchesSubtype() {
+        throw new IllegalStateException('boom')
+    }
+
+    @Test
+    @ExpectedToFail(messageContains = 'cosmic ray')
+    void messageFilterMatchesSubstring() {
+        throw new RuntimeException('hit by a cosmic ray')
+    }
+
+    @Test
+    @ExpectedToFail(value = IllegalArgumentException, messageContains = 'bad')
+    void typeAndMessageFiltersBothMatch() {
+        throw new IllegalArgumentException('this is bad input')
+    }
+
+    // ---------------- composition with @ForkedJvm ----------------
+
+    @Test
+    @ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
+    @ForkedJvm
+    void outerOrdering_failurePropagatesFromForkAndIsInverted() {
+        // ExpectedToFail OUTER: parent does the inversion AFTER @ForkedJvm
+        // serialises the failure across the JVM boundary, exercising the
+        // round-trip and verifying type+message fidelity.
+        throw new AssertionError('forked failure round-trips')
+    }
+
+    @Test
+    @ForkedJvm
+    @ExpectedToFail(value = AssertionError, messageContains = 'forked failure')
+    void innerOrdering_inversionHappensInsideForkedChild() {
+        // ExpectedToFail INNER: child JVM swallows the failure; parent sees
+        // success. Doesn't exercise the parent's view of the propagation.
+        throw new AssertionError('forked failure handled inside child')
+    }
+
+    // ---------------- negative paths (verified via Launcher) ----------------
+
+    @Test
+    void unexpectedlyPassingTestIsReportedAsFailure() {
+        TestExecutionSummary summary = runFixture(BadFixtures, 'passingMethod')
+        assertEquals(1, summary.totalFailureCount)
+        assertTrue(summary.failures[0].exception.message.contains('expected to 
fail but passed'))
+    }
+
+    @Test
+    void typeFilterMismatchIsReportedAsFailure() {
+        TestExecutionSummary summary = runFixture(BadFixtures, 
'wrongTypeMethod')
+        assertEquals(1, summary.totalFailureCount)
+        def msg = summary.failures[0].exception.message
+        assertTrue(msg.contains('expected exception of type'),
+                "actual: ${msg}")
+        assertTrue(msg.contains('IllegalStateException'),
+                "actual: ${msg}")
+    }
+
+    @Test
+    void messageFilterMismatchIsReportedAsFailure() {
+        TestExecutionSummary summary = runFixture(BadFixtures, 
'wrongMessageMethod')
+        assertEquals(1, summary.totalFailureCount)
+        def msg = summary.failures[0].exception.message
+        assertTrue(msg.contains('expected message containing'),
+                "actual: ${msg}")
+    }
+
+    @Test
+    void assumptionAbortIsNotSwallowed() {
+        TestExecutionSummary summary = runFixture(BadFixtures, 
'assumptionMethod')
+        // Aborts count as skipped, not failed; but they're definitely NOT
+        // counted as passes — that's the property we care about.
+        assertEquals(0, summary.totalFailureCount)
+        assertTrue(summary.testsAbortedCount >= 1,
+                "expected at least one aborted test, summary: ${summary}")
+    }
+
+    @Test
+    void reasonAttributeAppearsInUnexpectedlyPassingFailure() {
+        TestExecutionSummary summary = runFixture(BadFixtures, 
'reasonedPassingMethod')
+        assertEquals(1, summary.totalFailureCount)
+        assertTrue(summary.failures[0].exception.message.contains('GROOVY-9999 
placeholder'),
+                "actual: ${summary.failures[0].exception.message}")
+    }
+
+    // ---------------- helper ----------------
+
+    private static TestExecutionSummary runFixture(Class<?> fixtureClass, 
String methodName) {
+        def listener = new SummaryGeneratingListener()
+        def launcher = LauncherFactory.create()
+        launcher.registerTestExecutionListeners(listener)
+        launcher.execute(LauncherDiscoveryRequestBuilder.request()
+                .selectors(DiscoverySelectors.selectMethod(fixtureClass, 
methodName))
+                .build())
+        listener.summary
+    }
+
+    /**
+     * Fixtures used as inputs to the negative-path tests; tagged
+     * {@code manual} so the suite-level config excludes them from normal
+     * discovery — we invoke them explicitly via the Launcher.
+     */
+    @Tag('manual')
+    static class BadFixtures {
+        @Test
+        @ExpectedToFail
+        void passingMethod() {
+            // returns normally — should be reported as failure
+        }
+
+        @Test
+        @ExpectedToFail(IllegalStateException)
+        void wrongTypeMethod() {
+            throw new IllegalArgumentException('wrong type')
+        }
+
+        @Test
+        @ExpectedToFail(messageContains = 'foo')
+        void wrongMessageMethod() {
+            throw new RuntimeException('bar')
+        }
+
+        @Test
+        @ExpectedToFail
+        void assumptionMethod() {
+            Assumptions.assumeTrue(false, 'unconditional abort')
+        }
+
+        @Test
+        @ExpectedToFail(reason = 'GROOVY-9999 placeholder')
+        void reasonedPassingMethod() {
+            // returns normally
+        }
+    }
+}
diff --git 
a/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy 
b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
new file mode 100644
index 0000000000..34ff890ec4
--- /dev/null
+++ b/subprojects/groovy-test-junit6/src/test/groovy/ForkedJvmTest.groovy
@@ -0,0 +1,124 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+import groovy.junit6.plugin.ExpectedToFail
+import groovy.junit6.plugin.ForkedJvm
+import org.junit.jupiter.api.Assumptions
+import org.junit.jupiter.api.Tag
+import org.junit.jupiter.api.Test
+import org.junit.platform.engine.discovery.DiscoverySelectors
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder
+import org.junit.platform.launcher.core.LauncherFactory
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener
+
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+class ForkedJvmTest {
+
+    @Test
+    @ForkedJvm(systemProperties = ['groovy.junit6.test.example=hello'])
+    void runsInChildJvmWithSystemProperty() {
+        assertEquals('hello', System.getProperty('groovy.junit6.test.example'))
+    }
+
+    @Test
+    void unannotatedTestRunsInParentJvm() {
+        // No @ForkedJvm, no fork: the property set only in the forked test 
above
+        // should be invisible here.
+        assertNull(System.getProperty('groovy.junit6.test.example'))
+    }
+
+    @Test
+    @ForkedJvm(systemProperties = ['groovy.junit6.test.a=1', 
'groovy.junit6.test.b=2'])
+    void multipleSystemPropertiesArePassed() {
+        assertEquals('1', System.getProperty('groovy.junit6.test.a'))
+        assertEquals('2', System.getProperty('groovy.junit6.test.b'))
+    }
+
+    @Test
+    @ForkedJvm(jvmArgs = ['-Xmx64m'])
+    void jvmArgIsApplied() {
+        // -Xmx64m is innocuous; just assert we're in a fresh JVM by checking
+        // the heap max is at most ~64m (with some headroom for JVM overhead).
+        long max = Runtime.runtime.maxMemory()
+        assertTrue(max < 128L * 1024 * 1024,
+                "expected max heap < 128m under -Xmx64m, was ${max}")
+    }
+
+    @Test
+    @ForkedJvm(systemProperties = ['groovy.junit6.test.recursion=outer'])
+    void doesNotRecursivelyFork() {
+        // If recursion guard worked, the FORKED_FLAG is true here in the 
child.
+        assertEquals('true', System.getProperty('groovy.junit6.forked'))
+        assertEquals('outer', 
System.getProperty('groovy.junit6.test.recursion'))
+    }
+
+    @Test
+    @ForkedJvm
+    void emptyAnnotationStillForks() {
+        // No properties or args, but the FORKED_FLAG should still be set.
+        assertEquals('true', System.getProperty('groovy.junit6.forked'))
+    }
+
+    @Test
+    @ExpectedToFail(value = AssertionError, messageContains = 'expected 
failure from forked child')
+    @ForkedJvm
+    void failureInChildJvmPropagatesToParent() {
+        // @ExpectedToFail is OUTER (declared before @ForkedJvm) so the
+        // inversion happens in the parent JVM AFTER @ForkedJvm propagates the
+        // failure across the fork boundary. The type-and-message filters
+        // verify the failure round-tripped without losing fidelity.
+        throw new AssertionError('expected failure from forked child')
+    }
+
+    @Test
+    void abortInChildJvmPropagatesAsAbortNotPass() {
+        // Run the AbortFixture's @ForkedJvm test (which calls
+        // Assumptions.assumeFalse(true)) via the Launcher and assert that the
+        // outcome is reported as ABORTED, not PASSED. Regression guard for
+        // Copilot review comment #2.
+        def listener = new SummaryGeneratingListener()
+        def launcher = LauncherFactory.create()
+        launcher.registerTestExecutionListeners(listener)
+        launcher.execute(LauncherDiscoveryRequestBuilder.request()
+                .selectors(DiscoverySelectors.selectMethod(AbortFixture, 
'aborts'))
+                .build())
+
+        def summary = listener.summary
+        assertEquals(0, summary.totalFailureCount,
+                "expected no failures, got: ${summary.failures*.exception}")
+        assertEquals(1, summary.testsAbortedCount,
+                "expected exactly one aborted test, summary: 
tests=${summary.testsFoundCount} aborted=${summary.testsAbortedCount} 
succeeded=${summary.testsSucceededCount}")
+    }
+
+    /**
+     * Fixture for {@link #abortInChildJvmPropagatesAsAbortNotPass}. Tagged
+     * {@code manual} so the suite-level config excludes it from normal runs.
+     */
+    @Tag('manual')
+    static class AbortFixture {
+        @Test
+        @ForkedJvm
+        void aborts() {
+            Assumptions.assumeFalse(true, 'unconditional abort from forked 
child')
+        }
+    }
+}

Reply via email to