Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,8 @@ nb-configuration.xml
## OS X
##############################
.DS_Store

##############################
# Vibe Coding
##############################
scratchpad/
16 changes: 15 additions & 1 deletion docs/src/docs/asciidoc/changelog.adoc
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
[[changelog]]
== Changelog

== Release 0.11.5

- Feature – Auto-fallback to JVM JUnit when -H:+CompatibilityMode is enabled
+
* When -H:+CompatibilityMode is present in native-image options, native-image JUnit tests are disabled and JVM tests run automatically.
* Detection sources: native-image build arguments and NATIVE_IMAGE_OPTIONS environment variable.
* Gradle: test image wiring for the native JUnit launcher and JUnit Platform feature is gated; native test image build/run tasks are skipped via onlyIf; once-per-build log:
+
"Compatibility Mode detected (-H:+CompatibilityMode); The native test image will be built using the original JUnit ConsoleLauncher."

* Maven: native test goal short-circuits after environment setup; once-per-build log:
+
"Compatibility Mode detected (-H:+CompatibilityMode); The native test image will be built using the original JUnit ConsoleLauncher."
* Default behavior unchanged when the flag is absent (native-image JUnit path continues as before).

== Release 0.11.3

- Fixed use of argument file when the temporary directory is not on the same drive as the project
Expand Down Expand Up @@ -449,7 +464,6 @@ graalvmNative {
}
}
----

- The `nativeBuild` task has been renamed to `nativeCompile`.
- The `nativeTestBuild` task has been renamed to `nativeTestCompile`.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2026, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* The Universal Permissive License (UPL), Version 1.0
*
* Subject to the condition set forth below, permission is hereby granted to any
* person obtaining a copy of this software, associated documentation and/or
* data (collectively the "Software"), free of charge and under any and all
* copyright rights in the Software, and any and all patent rights owned or
* freely licensable by each licensor hereunder covering either (i) the
* unmodified Software as contributed to or provided by such licensor, or (ii)
* the Larger Works (as defined below), to deal in both
*
* (a) the Software, and
*
* (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if
* one is included with the Software each a "Larger Work" to which the Software
* is contributed by such licensors),
*
* without restriction, including without limitation the rights to copy, create
* derivative works of, display, perform, and distribute the Software and make,
* use, sell, offer for sale, import, export, have made, and have sold the
* Software and the Larger Work(s), and to sublicense the foregoing rights on
* either these or other terms.
*
* This license is subject to the following condition:
*
* The above copyright notice and either this complete permission notice or at a
* minimum a reference to the UPL must be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package org.graalvm.buildtools.gradle

import org.graalvm.buildtools.gradle.fixtures.AbstractFunctionalTest
import org.gradle.api.JavaVersion
import spock.lang.Requires
import spock.lang.Unroll

@Requires({ JavaVersion.current().isCompatibleWith(JavaVersion.toVersion(25)) })
class CompatibilityModeNativeTestsFunctionalTest extends AbstractFunctionalTest {

def "OFF: native test image build/run execute when Compatibility Mode is not enabled"() {
given:
withSample("java-application-with-tests")

when:
run 'nativeTest'

then:
tasks {
succeeded ':testClasses', ':nativeTestCompile', ':nativeTest'
}
outputDoesNotContain "Compatibility Mode detected (-H:+CompatibilityMode); The native test image will be built using the original JUnit ConsoleLauncher."
}

@Unroll
def "ON via buildArgs: native test image build/run are skipped and message logged"() {
given:
withSample("java-application-with-tests")
buildFile << """
graalvmNative {
binaries {
test {
buildArgs.add("-H:+CompatibilityMode")
}
}
}
""".stripIndent()

when:
run 'nativeTest'

then:
tasks {
succeeded ':testClasses', ':nativeTestCompile', ':nativeTest'
}
outputContains "Compatibility Mode detected (-H:+CompatibilityMode); The native test image will be built using the original JUnit ConsoleLauncher."
}

def "ON via NATIVE_IMAGE_OPTIONS env map: native test image build/run are skipped and message logged"() {
given:
withSample("java-application-with-tests")
buildFile << """
graalvmNative {
binaries {
test {
environmentVariables.put("NATIVE_IMAGE_OPTIONS", "-H:+CompatibilityMode")
}
}
}
""".stripIndent()

when:
run 'nativeTest'

then:
tasks {
succeeded ':testClasses', ':nativeTestCompile', ':nativeTest'
}
outputContains "Compatibility Mode detected (-H:+CompatibilityMode); The native test image will be built using the original JUnit ConsoleLauncher."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import org.graalvm.buildtools.gradle.internal.GraalVMLogger;
import org.graalvm.buildtools.gradle.internal.GraalVMReachabilityMetadataService;
import org.graalvm.buildtools.gradle.internal.GradleUtils;
import org.graalvm.buildtools.gradle.internal.NativeImageExecutableLocator;
import org.graalvm.buildtools.gradle.internal.agent.AgentConfigurationFactory;
import org.graalvm.buildtools.gradle.tasks.BuildNativeImageTask;
import org.graalvm.buildtools.gradle.tasks.CollectReachabilityMetadata;
Expand All @@ -65,9 +66,9 @@
import org.graalvm.buildtools.gradle.tasks.UseLayerOptions;
import org.graalvm.buildtools.gradle.tasks.actions.CleanupAgentFilesAction;
import org.graalvm.buildtools.gradle.tasks.actions.MergeAgentFilesAction;
import org.graalvm.buildtools.gradle.tasks.scanner.JarAnalyzerTransform;
import org.graalvm.buildtools.utils.JUnitPlatformNativeDependenciesHelper;
import org.graalvm.buildtools.utils.JUnitUtils;
import org.graalvm.buildtools.gradle.tasks.scanner.JarAnalyzerTransform;
import org.graalvm.buildtools.utils.SharedConstants;
import org.graalvm.reachability.DirectoryConfiguration;
import org.gradle.api.Action;
Expand Down Expand Up @@ -100,7 +101,6 @@
import org.gradle.api.plugins.JavaLibraryPlugin;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider;
Expand Down Expand Up @@ -176,8 +176,15 @@ public class NativeImagePlugin implements Plugin<Project> {
private static final String REPOSITORY_COORDINATES = "org.graalvm.buildtools:graalvm-reachability-metadata:" + VersionInfo.NBT_VERSION + ":repository@zip";
private static final String DEFAULT_URI = String.format(METADATA_REPO_URL_TEMPLATE, VersionInfo.METADATA_REPO_VERSION);

// Compatibility Mode detection constants
private static final String COMPATIBILITY_MODE_TOKEN = "-H:+CompatibilityMode";
private static final String NATIVE_IMAGE_OPTIONS_ENV = "NATIVE_IMAGE_OPTIONS";

private GraalVMLogger logger;

// Exposed detection provider for test binaries (to be used by follow-up tasks)
private Provider<Boolean> compatModeEnabled;

@Inject
public ArchiveOperations getArchiveOperations() {
throw new UnsupportedOperationException();
Expand Down Expand Up @@ -707,6 +714,71 @@ public void registerTestBinary(Project project,
// Add DSL extension for testing
NativeImageOptions testOptions = createTestOptions(graalExtension, name, project, mainOptions, config.getSourceSet());

// Compute and expose the Compatibility Mode detection provider for test binary
this.compatModeEnabled = computeCompatibilityModeEnabledProvider(project, testOptions);

// Unified once-per-build log at configuration time if Compatibility Mode is enabled
project.afterEvaluate(p -> {
if (compatModeEnabled().getOrElse(false)) {
logger.logOnce("Compatibility Mode detected (-H:+CompatibilityMode); The native test image will be built using the original JUnit ConsoleLauncher.");
}
});

// Wire main class based on Compatibility Mode, mirroring Maven plugin behavior.
testOptions.getMainClass().convention(compatModeEnabled().map(c -> c
? "org.junit.platform.console.ConsoleLauncher"
: "org.graalvm.junit.platform.NativeImageJUnitLauncher"));

// Add the JUnit Platform Feature flag and exclude JUnit class init files only when NOT in Compatibility Mode.
final String junitPlatformFeatureFlag = "--features=org.graalvm.junit.platform.JUnitPlatformFeature";
project.afterEvaluate(p -> {
boolean compat = compatModeEnabled().getOrElse(false);
if (!compat) {
List<String> current = testOptions.getBuildArgs().getOrElse(Collections.emptyList());
if (!current.contains(junitPlatformFeatureFlag)) {
testOptions.getBuildArgs().add(junitPlatformFeatureFlag);
}
/* in version 5.12.0 JUnit added initialize-at-build-time properties files which we need to exclude */
testOptions.getBuildArgs().addAll(JUnitUtils.excludeJUnitClassInitializationFiles());
}
});
// Add XML output dir only in regular mode (not in Compatibility Mode)
Provider<String> xmlOutputDir = project.getLayout().getBuildDirectory()
.dir("test-results/" + name + "-native")
.map(d -> d.getAsFile().getAbsolutePath());
testOptions.getRuntimeArgs().addAll(
compatModeEnabled().zip(xmlOutputDir, serializableBiFunctionOf((compat, dir) ->
compat ? Collections.emptyList() : Arrays.asList("--xml-output-dir", dir)
))
);
// In Compatibility Mode, pass classpath and scan directive to the JUnit ConsoleLauncher to avoid
// "Please specify an explicit selector option or use --scan-class-path or --scan-modules"
Provider<String> cpString = project.getProviders().provider(() -> testOptions.getClasspath().getAsPath());
testOptions.getRuntimeArgs().addAll(
compatModeEnabled().zip(cpString, serializableBiFunctionOf((compat, cp) ->
compat ? Arrays.asList("-Djava.class.path=" + cp, "--scan-classpath") : Collections.<String>emptyList()
))
);
// In Compatibility Mode, also pass -Djava.home from the GraalVM used for the build
Provider<String> graalVmHome = project.getProviders().provider(() -> {
NativeImageExecutableLocator.Diagnostics d = new NativeImageExecutableLocator.Diagnostics();
File nativeImage = NativeImageExecutableLocator.findNativeImageExecutable(
testOptions.getJavaLauncher(),
graalExtension.getToolchainDetection().map(enabled -> !enabled),
graalvmHomeProvider(project.getProviders(), d),
getExecOperations(),
logger,
d
);
File parent = nativeImage.getParentFile();
return parent != null ? parent.getParent() : null;
});
testOptions.getRuntimeArgs().addAll(
compatModeEnabled().zip(graalVmHome, serializableBiFunctionOf((compat, home) ->
compat && home != null ? Collections.singletonList("-Djava.home=" + home) : Collections.<String>emptyList()
))
);

TaskProvider<Test> testTask = config.validate().getTestTask();
testTask.configure(test -> {
var testList = testResultsDir.dir(test.getName() + "/testlist");
Expand Down Expand Up @@ -850,23 +922,13 @@ private NativeImageOptions createTestOptions(GraalVMExtension graalExtension,
var configurations = project.getConfigurations();
setupExtensionConfigExcludes(testExtension, configurations);

testExtension.getMainClass().set("org.graalvm.junit.platform.NativeImageJUnitLauncher");
testExtension.getMainClass().finalizeValue();
testExtension.getImageName().convention(mainExtension.getImageName().map(name -> name + SharedConstants.NATIVE_TESTS_SUFFIX));

ListProperty<String> runtimeArgs = testExtension.getRuntimeArgs();
runtimeArgs.add("--xml-output-dir");
runtimeArgs.add(project.getLayout().getBuildDirectory().dir("test-results/" + binaryName + "-native").map(d -> d.getAsFile().getAbsolutePath()));

testExtension.buildArgs("--features=org.graalvm.junit.platform.JUnitPlatformFeature");
ConfigurableFileCollection classpath = testExtension.getClasspath();
classpath.from(configurations.getByName(imageClasspathConfigurationNameFor(binaryName)));
classpath.from(sourceSet.getOutput().getClassesDirs());
classpath.from(sourceSet.getOutput().getResourcesDir());

/* in version 5.12.0 JUnit added initialize-at-build-time properties files which we need to exclude */
testExtension.getBuildArgs().addAll(JUnitUtils.excludeJUnitClassInitializationFiles());

return testExtension;
}

Expand Down Expand Up @@ -1069,4 +1131,51 @@ public List<String> getExcludes() {
}
}

// -----------------------------
// Compatibility Mode Detection
// -----------------------------

/**
* Exposes the Compatibility Mode detection for the test binary.
* Follow-up tasks will use this provider to gate configuration/skip tasks.
*/
public Provider<Boolean> compatModeEnabled() {
return compatModeEnabled;
}

private static Provider<Boolean> computeCompatibilityModeEnabledProvider(Project project, NativeImageOptions options) {
ProviderFactory providers = project.getProviders();

// System environment: NATIVE_IMAGE_OPTIONS
Provider<Boolean> fromSystemEnv = providers.environmentVariable(NATIVE_IMAGE_OPTIONS_ENV)
.map(NativeImagePlugin::containsCompatibilityTokenInString)
.orElse(false);

// Options-level environment variables
Provider<Boolean> fromOptionsEnv = options.getEnvironmentVariables()
.map(env -> {
Object v = env.get(NATIVE_IMAGE_OPTIONS_ENV);
return v != null && containsCompatibilityTokenInString(String.valueOf(v));
})
.orElse(false);

// Build args on the test options
Provider<Boolean> fromBuildArgs = options.getBuildArgs()
.map(NativeImagePlugin::containsCompatibilityTokenInArgs)
.orElse(false);

// Combine: true if any source enables compatibility mode
Provider<Boolean> anyEnv = fromSystemEnv.zip(fromOptionsEnv, (a, b) -> a || b);
return anyEnv.zip(fromBuildArgs, (ab, c) -> ab || c);
}

private static boolean containsCompatibilityTokenInString(String value) {
return value != null && value.contains(COMPATIBILITY_MODE_TOKEN);
}

public static boolean containsCompatibilityTokenInArgs(List<String> args) {
return args != null && args.stream()
.filter(Objects::nonNull)
.anyMatch(s -> s.equals(COMPATIBILITY_MODE_TOKEN));
}
}
Loading
Loading