+
+ private var mergedTree: ObjectNode? = null
+
+ override fun canTransformResource(element: FileTreeElement): Boolean {
+ return resource.get().equals(element.path, ignoreCase = true)
+ }
+
+ override fun transform(context: TransformerContext) {
+ val tree = JsonMapper().readTree(context.inputStream) as ObjectNode
+ val merged = mergedTree
+ if (merged == null) {
+ mergedTree = tree
+ } else {
+ tree.properties().forEach { (name, value) ->
+ value as ArrayNode
+ val existing = merged.get(name)
+ if (existing == null) {
+ merged.set(name, value)
+ } else {
+ existing as ArrayNode
+ existing.addAll(value)
+ }
+ }
+ }
+ }
+
+ override fun hasTransformedResource(): Boolean = mergedTree != null
+
+ override fun modifyOutputStream(os: ZipOutputStream, preserveFileTimestamps: Boolean) {
+ val entry = ZipEntry(resource.get())
+ entry.time = ShadowJar.CONSTANT_TIME_FOR_ZIP_ENTRIES
+ os.putNextEntry(entry)
+ os.write(JsonMapper().writeValueAsBytes(mergedTree))
+ os.closeEntry()
+ mergedTree = null
+ }
+
+}
diff --git a/junit-jupiter-api/junit-jupiter-api.gradle.kts b/junit-jupiter-api/junit-jupiter-api.gradle.kts
index 4fe91a3dcf42..c52c9cf784e6 100644
--- a/junit-jupiter-api/junit-jupiter-api.gradle.kts
+++ b/junit-jupiter-api/junit-jupiter-api.gradle.kts
@@ -9,6 +9,8 @@ plugins {
description = "JUnit Jupiter API"
dependencies {
+ annotationProcessor(projects.junitPlatformConfigurationProcessor)
+
api(platform(projects.junitBom))
api(libs.opentest4j)
api(projects.junitPlatformCommons)
@@ -17,6 +19,7 @@ dependencies {
compileOnlyApi(libs.jspecify)
compileOnly(kotlin("stdlib"))
+ compileOnly(projects.junitPlatformConfigurationApi)
testFixturesImplementation(libs.assertj)
testFixturesImplementation(testFixtures(projects.junitPlatformCommons))
@@ -35,7 +38,7 @@ eclipseConventions {
tasks {
compileJava {
- options.compilerArgs.add("-Xlint:-module") // due to qualified exports
+ options.compilerArgs.add("-Xlint:-module,-processing") // -module: due to qualified exports, -processing: not all annotations need to be processed
}
jar {
bundle {
diff --git a/junit-jupiter-api/src/main/java/module-info.java b/junit-jupiter-api/src/main/java/module-info.java
index eb0df11c066b..583a9701aa62 100644
--- a/junit-jupiter-api/src/main/java/module-info.java
+++ b/junit-jupiter-api/src/main/java/module-info.java
@@ -21,6 +21,7 @@
requires transitive org.junit.platform.commons;
requires transitive org.opentest4j;
+ requires static org.junit.platform.configuration.api;
requires static kotlin.stdlib;
exports org.junit.jupiter.api;
diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java
index f1c38975ed51..146a22256081 100644
--- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java
+++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/Constants.java
@@ -13,13 +13,22 @@
import static org.apiguardian.api.API.Status.EXPERIMENTAL;
import static org.apiguardian.api.API.Status.INTERNAL;
import static org.apiguardian.api.API.Status.STABLE;
+import static org.junit.platform.configuration.api.ConfigurationParameter.Value;
+
+import java.math.BigDecimal;
import org.apiguardian.api.API;
+import org.junit.jupiter.api.DisplayNameGenerator.Standard;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.junit.jupiter.api.extension.PreInterruptCallback;
import org.junit.jupiter.api.extension.TestInstantiationAwareExtension.ExtensionContextScope;
import org.junit.jupiter.api.io.CleanupMode;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.io.TempDirDeletionStrategy;
+import org.junit.jupiter.api.io.TempDirFactory;
import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+import org.junit.platform.configuration.api.ConfigurationParameter;
/**
* Collection of configuration constants for the Jupiter test engine.
@@ -64,6 +73,8 @@ public final class Constants {
*
* Note: A class that matches both an inclusion and exclusion pattern will be excluded.
*/
+ // TODO: ClassNamePatternFilterUtils.ALL_PATTERN isn't part of the public API
+ @ConfigurationParameter(defaultValue = @Value(stringValue = "*"))
public static final String EXTENSIONS_AUTODETECTION_INCLUDE_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.include";
/**
@@ -99,6 +110,7 @@ public final class Constants {
*
*
Note: A class that matches both an inclusion and exclusion pattern will be excluded.
*/
+ @ConfigurationParameter(type = String.class)
public static final String EXTENSIONS_AUTODETECTION_EXCLUDE_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.exclude";
/**
@@ -107,6 +119,8 @@ public final class Constants {
*
*
The default behavior is not to perform auto-detection.
*/
+ // TODO: Handle @link references.
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String EXTENSIONS_AUTODETECTION_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.enabled";
/**
@@ -115,6 +129,7 @@ public final class Constants {
*
By default, auto-closing is enabled.
*
*/
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = true))
public static final String CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.store.close.autocloseable.enabled";
/**
@@ -151,6 +166,7 @@ public final class Constants {
* @see #DEACTIVATE_ALL_CONDITIONS_PATTERN
* @see org.junit.jupiter.api.extension.ExecutionCondition
*/
+ @ConfigurationParameter(type = String.class)
public static final String DEACTIVATE_CONDITIONS_PATTERN_PROPERTY_NAME = "junit.jupiter.conditions.deactivate";
/**
@@ -166,6 +182,7 @@ public final class Constants {
*
* @see DisplayNameGenerator#DEFAULT_GENERATOR_PROPERTY_NAME
*/
+ @ConfigurationParameter(type = DisplayNameGenerator.class, defaultValue = @Value(classValue = Standard.class))
public static final String DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME = DisplayNameGenerator.DEFAULT_GENERATOR_PROPERTY_NAME;
/**
@@ -174,14 +191,17 @@ public final class Constants {
*
*
This behavior is disabled by default.
*/
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String EXTENSIONS_TIMEOUT_THREAD_DUMP_ENABLED_PROPERTY_NAME = PreInterruptCallback.THREAD_DUMP_ENABLED_PROPERTY_NAME;
/**
* Property name used to set the default test instance lifecycle mode: {@value}
*
- * @see TestInstance.Lifecycle#DEFAULT_LIFECYCLE_PROPERTY_NAME
+ * @see Lifecycle#DEFAULT_LIFECYCLE_PROPERTY_NAME
*/
- public static final String DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME = TestInstance.Lifecycle.DEFAULT_LIFECYCLE_PROPERTY_NAME;
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(type = Lifecycle.class, defaultValue = @Value(stringValue = "per_method"))
+ public static final String DEFAULT_TEST_INSTANCE_LIFECYCLE_PROPERTY_NAME = Lifecycle.DEFAULT_LIFECYCLE_PROPERTY_NAME;
/**
* Property name used to enable parallel test execution: {@value}
@@ -189,6 +209,7 @@ public final class Constants {
*
By default, tests are executed sequentially in a single thread.
*
*/
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled";
/**
@@ -196,6 +217,8 @@ public final class Constants {
*
* @see Execution#DEFAULT_EXECUTION_MODE_PROPERTY_NAME
*/
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(type = ExecutionMode.class, defaultValue = @Value(stringValue = "same_thread"))
public static final String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME;
/**
@@ -204,6 +227,7 @@ public final class Constants {
*
* @see Execution#DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME
*/
+ @ConfigurationParameter(type = ExecutionMode.class)
public static final String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME;
/**
@@ -221,6 +245,9 @@ public final class Constants {
* ignoring case.
*
*/
+ // TODO: ParallelExecutorServiceType is not part of the public API
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(/* type = ParallelExecutorServiceType.class,*/ defaultValue = @Value(stringValue = "fork_join_pool"))
public static final String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX
+ "executor-service";
@@ -232,6 +259,9 @@ public final class Constants {
* {@code custom}.
*
*/
+ // TODO: DefaultParallelExecutionConfigurationStrategy is not part of the public API
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(/* type = DefaultParallelExecutionConfigurationStrategy.class,*/ defaultValue = @Value(stringValue = "dynamic"))
public static final String PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + "strategy";
/**
@@ -241,6 +271,7 @@ public final class Constants {
*
No default value; must be a positive integer.
*
*/
+ @ConfigurationParameter(type = Integer.class)
public static final String PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX
+ "fixed.parallelism";
@@ -253,6 +284,7 @@ public final class Constants {
* {@code 256 + fixed.parallelism}.
*
*/
+ @ConfigurationParameter(type = Integer.class)
public static final String PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX
+ "fixed.max-pool-size";
@@ -267,6 +299,7 @@ public final class Constants {
*
Value must either {@code true} or {@code false}; defaults to {@code true}.
*
*/
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = true))
public static final String PARALLEL_CONFIG_FIXED_SATURATE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + "fixed.saturate";
/**
@@ -277,6 +310,7 @@ public final class Constants {
*
Value must be a positive decimal number; defaults to {@code 1}.
*
*/
+ @ConfigurationParameter(type = BigDecimal.class, defaultValue = @Value(doubleValue = 1.0))
public static final String PARALLEL_CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + "dynamic.factor";
/**
@@ -285,6 +319,8 @@ public final class Constants {
* {@value}
*
*/
+ // TODO: ParallelExecutionConfigurationStrategy is not part of the public API
+ @ConfigurationParameter /*( type = ParallelExecutionConfigurationStrategy.class)*/
public static final String PARALLEL_CONFIG_CUSTOM_CLASS_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + "custom.class";
/**
@@ -293,6 +329,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_TIMEOUT_PROPERTY_NAME;
/**
@@ -300,6 +338,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_TESTABLE_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_TESTABLE_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_TESTABLE_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -308,6 +348,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_TEST_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_TEST_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_TEST_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -316,6 +358,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_TEST_TEMPLATE_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_TEST_TEMPLATE_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_TEST_TEMPLATE_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -324,6 +368,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_TEST_FACTORY_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_TEST_FACTORY_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_TEST_FACTORY_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -331,6 +377,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_LIFECYCLE_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_LIFECYCLE_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_LIFECYCLE_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -339,6 +387,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_BEFORE_ALL_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_BEFORE_ALL_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_BEFORE_ALL_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -347,6 +397,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_BEFORE_EACH_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_BEFORE_EACH_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_BEFORE_EACH_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -355,6 +407,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_AFTER_EACH_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_AFTER_EACH_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_AFTER_EACH_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -363,6 +417,8 @@ public final class Constants {
*
* @see Timeout#DEFAULT_AFTER_ALL_METHOD_TIMEOUT_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String DEFAULT_AFTER_ALL_METHOD_TIMEOUT_PROPERTY_NAME = Timeout.DEFAULT_AFTER_ALL_METHOD_TIMEOUT_PROPERTY_NAME;
/**
@@ -370,6 +426,8 @@ public final class Constants {
*
* @see Timeout#TIMEOUT_MODE_PROPERTY_NAME
*/
+ // TODO: TimeoutDuration is not part of the public API
+ @ConfigurationParameter /*(type = TimeoutDuration.class)*/
public static final String TIMEOUT_MODE_PROPERTY_NAME = Timeout.TIMEOUT_MODE_PROPERTY_NAME;
/**
@@ -377,6 +435,7 @@ public final class Constants {
*
* @see MethodOrderer#DEFAULT_ORDER_PROPERTY_NAME
*/
+ @ConfigurationParameter(type = MethodOrderer.class)
public static final String DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME = MethodOrderer.DEFAULT_ORDER_PROPERTY_NAME;
/**
@@ -384,6 +443,7 @@ public final class Constants {
*
* @see ClassOrderer#DEFAULT_ORDER_PROPERTY_NAME
*/
+ @ConfigurationParameter(type = ClassOrderer.class)
public static final String DEFAULT_TEST_CLASS_ORDER_PROPERTY_NAME = ClassOrderer.DEFAULT_ORDER_PROPERTY_NAME;
/**
@@ -392,6 +452,8 @@ public final class Constants {
* @see Timeout
* @see Timeout.ThreadMode
*/
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(type = Timeout.ThreadMode.class, defaultValue = @Value(stringValue = "same_thread"))
public static final String DEFAULT_TIMEOUT_THREAD_MODE_PROPERTY_NAME = Timeout.DEFAULT_TIMEOUT_THREAD_MODE_PROPERTY_NAME;
/**
@@ -400,6 +462,7 @@ public final class Constants {
*
* @see TempDir#DEFAULT_FACTORY_PROPERTY_NAME
*/
+ @ConfigurationParameter(type = TempDirFactory.class, defaultValue = @Value(classValue = TempDirFactory.Standard.class))
public static final String DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME = TempDir.DEFAULT_FACTORY_PROPERTY_NAME;
/**
@@ -409,6 +472,8 @@ public final class Constants {
*
* @see TempDir#DEFAULT_CLEANUP_MODE_PROPERTY_NAME
*/
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(type = CleanupMode.class, defaultValue = @Value(stringValue = "always"))
public static final String DEFAULT_TEMP_DIR_CLEANUP_MODE_PROPERTY_NAME = TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME;
/**
@@ -419,6 +484,7 @@ public final class Constants {
* @see TempDir#DEFAULT_DELETION_STRATEGY_PROPERTY_NAME
*/
@API(status = EXPERIMENTAL, since = "6.1")
+ @ConfigurationParameter(type = TempDirDeletionStrategy.class, defaultValue = @Value(classValue = TempDirDeletionStrategy.Standard.class))
public static final String DEFAULT_TEMP_DIR_DELETION_STRATEGY_PROPERTY_NAME = TempDir.DEFAULT_DELETION_STRATEGY_PROPERTY_NAME;
/**
@@ -427,6 +493,8 @@ public final class Constants {
*
* @see org.junit.jupiter.api.extension.TestInstantiationAwareExtension
*/
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(type = ExtensionContextScope.class, defaultValue = @Value(stringValue = "default"))
public static final String DEFAULT_TEST_CLASS_INSTANCE_CONSTRUCTION_EXTENSION_CONTEXT_SCOPE_PROPERTY_NAME = ExtensionContextScope.DEFAULT_SCOPE_PROPERTY_NAME;
private Constants() {
diff --git a/junit-platform-configuration-api/junit-platform-configuration-api.gradle.kts b/junit-platform-configuration-api/junit-platform-configuration-api.gradle.kts
new file mode 100644
index 000000000000..ae69a8b20955
--- /dev/null
+++ b/junit-platform-configuration-api/junit-platform-configuration-api.gradle.kts
@@ -0,0 +1,29 @@
+plugins {
+ id("junitbuild.java-library-conventions")
+}
+
+description = "JUnit Platform Configuration API"
+
+dependencies {
+ api(platform(projects.junitBom))
+
+ compileOnlyApi(libs.apiguardian)
+ compileOnlyApi(libs.jspecify)
+}
+
+backwardCompatibilityChecks {
+ enabled = false // not yet released
+}
+
+tasks.jar {
+ bundle {
+ bnd(
+ """
+ Import-Package: \
+ ${extra["importAPIGuardian"]},\
+ ${extra["importJSpecify"]},\
+ *
+ """
+ )
+ }
+}
diff --git a/junit-platform-configuration-api/src/main/java/module-info.java b/junit-platform-configuration-api/src/main/java/module-info.java
new file mode 100644
index 000000000000..dfa9067462b7
--- /dev/null
+++ b/junit-platform-configuration-api/src/main/java/module-info.java
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2015-2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+module org.junit.platform.configuration.api {
+ requires static transitive org.jspecify;
+ requires static transitive org.apiguardian.api;
+
+ exports org.junit.platform.configuration.api;
+}
diff --git a/junit-platform-configuration-api/src/main/java/org/junit/platform/configuration/api/ConfigurationParameter.java b/junit-platform-configuration-api/src/main/java/org/junit/platform/configuration/api/ConfigurationParameter.java
new file mode 100644
index 000000000000..23c3e058ac7e
--- /dev/null
+++ b/junit-platform-configuration-api/src/main/java/org/junit/platform/configuration/api/ConfigurationParameter.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.api;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.apiguardian.api.API;
+
+/**
+ * Marks a field as a configuration parameter for a test engine.
+ *
+ * This annotation should be used to facilitate the automated
+ * generation of documentation.
+ */
+@API(status = API.Status.EXPERIMENTAL)
+@Retention(RetentionPolicy.SOURCE)
+@Target(ElementType.FIELD)
+public @interface ConfigurationParameter {
+
+ /**
+ * The type of the data type of the parameter.
+ *
+ * If the type is left blank, the type of the default value is used.
+ *
+ * @return the signature of the data type of the parameter.
+ */
+ Class> type() default Void.class;
+
+ /**
+ * The default value used if the parameter is not specified.
+ * At most one value may be set.
+ *
+ * @return the default values.
+ */
+ Value defaultValue() default @Value;
+
+ /**
+ * Specifies that the parameter is deprecated.
+ *
+ * @return details about the deprecation.
+ */
+ Deprecation deprecation() default @Deprecation;
+
+ @interface Deprecation {
+ /**
+ * A brief description of why the parameter was deprecated.
+ *
+ * The description should be one or more short paragraphs, ending with a period.
+ *
+ * @return a description of why the parameter was deprecated.
+ */
+ String reason() default "";
+
+ /**
+ * The full name of the replacement parameter.
+ *
+ * @return the full name of the replacement parameter.
+ */
+ String replacement() default "";
+
+ /**
+ * The version of the API when the parameter was deprecated.
+ *
+ * @return the version of the API when the parameter was deprecated.
+ */
+ String since() default "";
+ }
+
+ /**
+ * The value to use as the default. At most one value may be set.
+ */
+ @interface Value {
+
+ /**
+ * The {@code short} value to use as the default.
+ */
+ short[] shortValue() default {};
+
+ /**
+ * The {@code byte} value to use as the default.
+ */
+ byte[] byteValue() default {};
+
+ /**
+ * The {@code int} value to use as the default.
+ */
+ int[] intValue() default {};
+
+ /**
+ * The {@code long} value to use as the default.
+ */
+ long[] longValue() default {};
+
+ /**
+ * The {@code float} value to use as the default.
+ */
+ float[] floatValue() default {};
+
+ /**
+ * The {@code double} value to use as the default.
+ */
+ double[] doubleValue() default {};
+
+ /**
+ * The {@code char} value to use as the default.
+ */
+ char[] charValue() default {};
+
+ /**
+ * The {@code boolean} value to use as the default.
+ */
+ boolean[] booleanValue() default {};
+
+ /**
+ * The {@link String} value to use as the default.
+ */
+ String[] stringValue() default {};
+
+ /**
+ * The {@link Class} value to use as the default.
+ */
+ Class>[] classValue() default {};
+ }
+
+}
diff --git a/junit-platform-configuration-api/src/main/java/org/junit/platform/configuration/api/package-info.java b/junit-platform-configuration-api/src/main/java/org/junit/platform/configuration/api/package-info.java
new file mode 100644
index 000000000000..4c97e5c75569
--- /dev/null
+++ b/junit-platform-configuration-api/src/main/java/org/junit/platform/configuration/api/package-info.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2012 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+@NullMarked
+package org.junit.platform.configuration.api;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/junit-platform-configuration-api/src/test/README.md b/junit-platform-configuration-api/src/test/README.md
new file mode 100644
index 000000000000..6e2fd0b363f0
--- /dev/null
+++ b/junit-platform-configuration-api/src/test/README.md
@@ -0,0 +1 @@
+For compatibility with the Eclipse IDE, the test for this module are in the `platform-tests` project.
diff --git a/junit-platform-configuration-processor/junit-platform-configuration-processor.gradle.kts b/junit-platform-configuration-processor/junit-platform-configuration-processor.gradle.kts
new file mode 100644
index 000000000000..2abb3c592344
--- /dev/null
+++ b/junit-platform-configuration-processor/junit-platform-configuration-processor.gradle.kts
@@ -0,0 +1,68 @@
+import junitbuild.extensions.javaModuleName
+
+plugins {
+ id("junitbuild.java-library-conventions")
+ id("junitbuild.shadow-conventions")
+}
+
+description = "JUnit Platform Configuration Processor"
+
+dependencies {
+ api(platform(projects.junitBom))
+ api(projects.junitPlatformConfigurationApi)
+
+ compileOnlyApi(libs.apiguardian)
+ compileOnlyApi(libs.jspecify)
+
+ shadowed(libs.jakarta.json.api)
+ // TODO: Check if shadowed artifact is standalone
+ shadowed(libs.jakarta.json.implementation)
+}
+
+backwardCompatibilityChecks {
+ enabled = false // not yet released
+}
+
+tasks {
+ compileJava {
+ options.compilerArgs.addAll(listOf(
+ "--add-modules", "jakarta.json",
+ "--add-reads", "${javaModuleName}=jakarta.json"
+ ))
+ }
+ javadoc {
+ (options as StandardJavadocDocletOptions).apply {
+ addStringOption("-add-modules", "jakarta.json")
+ addStringOption("-add-reads", "${javaModuleName}=jakarta.json")
+ }
+ }
+ val extractLicenses = register("extractLicenses", Sync::class) {
+ val classPathElements = configurations.shadowedClasspath.flatMap { it.elements }
+ from(zipTree(classPathElements.map { it.single { file -> file.asFile.name.contains("jakarta.json-api") } })) {
+ include("META-INF/LICENSE.md")
+ rename { "LICENSE-jakarta-json.md" }
+ }
+ from(zipTree(classPathElements.map { it.single { file -> file.asFile.name.contains("parsson") } })) {
+ include("META-INF/LICENSE.md")
+ rename { "LICENSE-parsson.md" }
+ }
+ into(layout.buildDirectory.dir("licenses"))
+ }
+ shadowJar {
+ bundle {
+ bnd(
+ """
+ Import-Package: \
+ ${extra["importAPIGuardian"]},\
+ ${extra["importJSpecify"]},\
+ *
+ """
+ )
+ }
+ relocate("jakarta.json", "org.junit.platform.configuration.processor.shadow.jakarta.json")
+ relocate("org.eclipse.parsson", "org.junit.platform.configuration.processor.shadow.org.eclipse.parsson")
+ exclude("META-INF/LICENSE.md", "META-INF/NOTICE.md")
+ from(extractLicenses)
+ mergeServiceFiles()
+ }
+}
diff --git a/junit-platform-configuration-processor/src/main/java/module-info.java b/junit-platform-configuration-processor/src/main/java/module-info.java
new file mode 100644
index 000000000000..1c134963564b
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/module-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2015-2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+module org.junit.platform.configuration.processor {
+ requires static transitive org.jspecify;
+ requires static transitive org.apiguardian.api;
+
+ requires java.compiler;
+ requires org.junit.platform.configuration.api;
+
+ provides javax.annotation.processing.Processor with org.junit.platform.configuration.processor.ConfigurationMetadataAnnotationProcessor;
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/AnnotationMirrorUtil.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/AnnotationMirrorUtil.java
new file mode 100644
index 000000000000..95fa1c13e324
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/AnnotationMirrorUtil.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import static java.util.stream.Collectors.toMap;
+
+import java.lang.annotation.Annotation;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.AnnotationValue;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
+
+import org.jspecify.annotations.Nullable;
+
+class AnnotationMirrorUtil {
+
+ private AnnotationMirrorUtil() {
+ /* no-op */
+ }
+
+ static @Nullable AnnotationMirror getAnnotationMirror(Element element, Class extends Annotation> annotationType) {
+ var elementName = annotationType.getName();
+ return element.getAnnotationMirrors().stream() //
+ .filter(annotation -> elementName.equals(annotation.getAnnotationType().toString())) //
+ .findFirst() //
+ .orElse(null);
+ }
+
+ static @Nullable AnnotationMirror getAnnotationMirror(AnnotationMirror annotation, String elementName) {
+ return annotation.getElementValues().entrySet() //
+ .stream() //
+ .filter(element -> elementName.equals(element.getKey().getSimpleName().toString())) //
+ .map(Entry::getValue) //
+ .map(AnnotationValue::getValue) //
+ .findFirst() //
+ .filter(AnnotationMirror.class::isInstance) //
+ .map(AnnotationMirror.class::cast) //
+ .orElse(null);
+ }
+
+ static Map> getValuesMap(AnnotationMirror annotation) {
+ return annotation.getElementValues().entrySet() //
+ .stream() //
+ .collect(toMap(AnnotationMirrorUtil::getSimpleName, AnnotationMirrorUtil::getValues));
+ }
+
+ static Map getStringValuesMap(AnnotationMirror annotation) {
+ return annotation.getElementValues().entrySet() //
+ .stream() //
+ .collect(toMap(AnnotationMirrorUtil::getSimpleName, AnnotationMirrorUtil::getStringValue));
+ }
+
+ private static List getValues(Entry extends ExecutableElement, ? extends AnnotationValue> entry) {
+ if (entry.getValue().getValue() instanceof List> values) {
+ return values.stream() //
+ .filter(AnnotationValue.class::isInstance) //
+ .map(AnnotationValue.class::cast) //
+ .map(AnnotationValue::getValue) //
+ .toList();
+ }
+ return Collections.emptyList();
+ }
+
+ private static String getStringValue(Entry extends ExecutableElement, ? extends AnnotationValue> entry) {
+ Object value = entry.getValue().getValue();
+ return value.toString();
+ }
+
+ private static String getSimpleName(Entry extends ExecutableElement, ? extends AnnotationValue> entry) {
+ return entry.getKey().getSimpleName().toString();
+ }
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetaData.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetaData.java
new file mode 100644
index 000000000000..aad773dbde34
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetaData.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * See Spring Boot - Specifications - Configuration Metadata - Metadata Format .
+ */
+final class ConfigurationMetaData {
+
+ private final List properties = new ArrayList<>();
+
+ List properties() {
+ return properties;
+ }
+
+ void addProperty(Property property) {
+ properties.add(property);
+ }
+
+ record Property( //
+ String name, //
+ @Nullable String type, //
+ @Nullable String description, //
+ @Nullable String sourceType, //
+ @Nullable Object defaultValue, //
+ @Nullable Deprecation deprecation //
+ ) {
+
+ }
+
+ record Deprecation( //
+ @Nullable Level level, //
+ @Nullable String reason, //
+ @Nullable String replacement, //
+ @Nullable String since //
+ ) {
+
+ enum Level {
+ WARNING("warning"), ERROR("error");
+
+ private final String value;
+
+ Level(String value) {
+ this.value = value;
+ }
+
+ String value() {
+ return value;
+ }
+ }
+ }
+
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessor.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessor.java
new file mode 100644
index 000000000000..6e904454327c
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessor.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.util.Set;
+
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.TypeElement;
+import javax.tools.StandardLocation;
+
+import org.apiguardian.api.API;
+import org.jspecify.annotations.Nullable;
+
+import jakarta.json.Json;
+
+/// Writes all configuration parameters marked with
+/// {@link org.junit.platform.configuration.api.ConfigurationParameter} to
+/// {@value #METADATA_PATH} in [Spring Boot's Configuration
+/// Metadata](https://docs.spring.io/spring-boot/specification/configuration-metadata/format.html)
+/// format. This enables IDEs and other tools to process and validate Test Engine
+/// configuration.
+///
+/// Usage
+///
+/// {@code
+/// /**
+/// * A brief multi-line description of
+/// * this property: {@value}.
+/// *
+/// * Followed by an additional paragraph.
+/// */
+/// @ConfigurationProperty
+/// public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+///
+/// }
+///
+/// The first paragraph from the doc string will be used to describe the
+/// property. If the first paragraph ends with {@code : {@value}.} or
+/// {@code : {@value}} it will be replaced with a {@code : {@value}.}.
+///
+@API(status = API.Status.EXPERIMENTAL)
+@SupportedAnnotationTypes("org.junit.platform.configuration.api.ConfigurationParameter")
+public final class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor {
+ private static final String METADATA_PATH = "META-INF/junit-platform-configuration-metadata.json";
+ private @Nullable ConfigurationMetaData metaData;
+ private @Nullable ConfigurationParameterHandler configurationParameterHandler;
+
+ @Override
+ public synchronized void init(ProcessingEnvironment environment) {
+ super.init(environment);
+ this.metaData = new ConfigurationMetaData();
+ this.configurationParameterHandler = new ConfigurationParameterHandler(metaData,
+ processingEnv.getElementUtils(), processingEnv.getMessager());
+ }
+
+ @Override
+ public SourceVersion getSupportedSourceVersion() {
+ return SourceVersion.latestSupported();
+ }
+
+ @Override
+ @SuppressWarnings("DoNotClaimAnnotations")
+ public boolean process(Set extends TypeElement> annotations, RoundEnvironment roundEnv) {
+ requireNonNull(configurationParameterHandler).process(roundEnv);
+
+ if (roundEnv.processingOver()) {
+ writeMetaData();
+ }
+
+ // Very simple check. Works because this processor only processes
+ // ConfigurationParameter annotations.
+ return !annotations.isEmpty();
+ }
+
+ private void writeMetaData() {
+ var converter = new JsonConverter();
+ try (var out = new BufferedWriter(new OutputStreamWriter(openOutputStream(), UTF_8))) {
+ var value = converter.toJsonObject(requireNonNull(metaData));
+ Json.createWriter(out).write(value);
+ }
+ catch (Exception ex) {
+ var message = "Failed to write metadata to [%s]".formatted(METADATA_PATH);
+ throw new ConfigurationMetadataAnnotationProcessorException(message, ex);
+ }
+ }
+
+ private OutputStream openOutputStream() throws IOException {
+ var filer = requireNonNull(processingEnv).getFiler();
+ var resource = filer.createResource(StandardLocation.CLASS_OUTPUT, "", METADATA_PATH);
+ return resource.openOutputStream();
+ }
+
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessorException.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessorException.java
new file mode 100644
index 000000000000..a6cc63bb53e9
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessorException.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import java.io.Serial;
+
+final class ConfigurationMetadataAnnotationProcessorException extends RuntimeException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ ConfigurationMetadataAnnotationProcessorException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationParameterAnnotatedField.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationParameterAnnotatedField.java
new file mode 100644
index 000000000000..5812fb127b1c
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationParameterAnnotatedField.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import static org.junit.platform.configuration.processor.AnnotationMirrorUtil.getAnnotationMirror;
+import static org.junit.platform.configuration.processor.AnnotationMirrorUtil.getStringValuesMap;
+import static org.junit.platform.configuration.processor.AnnotationMirrorUtil.getValuesMap;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.util.Elements;
+
+import org.jspecify.annotations.Nullable;
+
+final class ConfigurationParameterAnnotatedField {
+ private final VariableElement element;
+ private final TypeElement enclosingType;
+ private final AnnotationMirror annotationMirror;
+ private final Elements elementUtils;
+
+ ConfigurationParameterAnnotatedField(VariableElement element, Elements elementUtils, TypeElement enclosingType,
+ AnnotationMirror annotationMirror) {
+ this.element = element;
+ this.elementUtils = elementUtils;
+ this.enclosingType = enclosingType;
+ this.annotationMirror = annotationMirror;
+ }
+
+ Element element() {
+ return element;
+ }
+
+ AnnotationMirror annotationMirror() {
+ return annotationMirror;
+ }
+
+ Map deprecationValues() {
+ var deprecation = getAnnotationMirror(annotationMirror, "deprecation");
+ if (deprecation == null) {
+ return Collections.emptyMap();
+ }
+ return getStringValuesMap(deprecation);
+ }
+
+ Map> defaultValues() {
+ var defaultValue = getAnnotationMirror(annotationMirror, "defaultValue");
+ if (defaultValue == null) {
+ return Collections.emptyMap();
+ }
+ return getValuesMap(defaultValue);
+ }
+
+ @Nullable
+ String typeValue() {
+ return getStringValuesMap(annotationMirror).get("type");
+ }
+
+ @Nullable
+ Object constantValue() {
+ return element.getConstantValue();
+ }
+
+ String name() {
+ return "%s.%s".formatted(enclosingType.getQualifiedName(), element.getSimpleName());
+ }
+
+ boolean isStatic() {
+ return element.getModifiers().contains(Modifier.STATIC);
+ }
+
+ boolean isFinal() {
+ return element.getModifiers().contains(Modifier.FINAL);
+ }
+
+ boolean isDeprecated() {
+ return getAnnotationMirror(element, Deprecated.class) != null;
+ }
+
+ @Nullable
+ String docComment() {
+ return elementUtils.getDocComment(element);
+ }
+
+ String enclosingTypeName() {
+ return enclosingType.getQualifiedName().toString();
+ }
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationParameterHandler.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationParameterHandler.java
new file mode 100644
index 000000000000..d3d507434914
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/ConfigurationParameterHandler.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import static java.util.Objects.requireNonNull;
+import static javax.tools.Diagnostic.Kind.ERROR;
+import static org.junit.platform.configuration.processor.AnnotationMirrorUtil.getAnnotationMirror;
+
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.RoundEnvironment;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.util.Elements;
+
+import org.jspecify.annotations.Nullable;
+import org.junit.platform.configuration.api.ConfigurationParameter;
+import org.junit.platform.configuration.processor.ConfigurationMetaData.Deprecation;
+import org.junit.platform.configuration.processor.ConfigurationMetaData.Property;
+
+final class ConfigurationParameterHandler {
+
+ private static final Map NAME_TO_TYPE_NAME = Map.of( //
+ "shortValue", Short.class.getName(), //
+ "byteValue", Byte.class.getName(), //
+ "intValue", Integer.class.getName(), //
+ "longValue", Long.class.getName(), //
+ "floatValue", Float.class.getName(), //
+ "doubleValue", Double.class.getName(), //
+ "charValue", Character.class.getName(), //
+ "booleanValue", Boolean.class.getName(), //
+ "stringValue", String.class.getName(), //
+ "classValue", Class.class.getName() //
+ );
+
+ private final ConfigurationMetaData metaData;
+ private final Elements elementUtils;
+ private final Messager messager;
+
+ ConfigurationParameterHandler(ConfigurationMetaData metaData, Elements elementUtils, Messager messager) {
+ this.metaData = metaData;
+ this.elementUtils = elementUtils;
+ this.messager = messager;
+ }
+
+ void process(RoundEnvironment roundEnv) {
+ roundEnv.getElementsAnnotatedWith(ConfigurationParameter.class).forEach(this::processElement);
+ }
+
+ private void processElement(Element element) {
+ if (!(element instanceof VariableElement variableElement)) {
+ messager.printMessage(ERROR, "@ConfigurationParameter annotated element was not a field", element);
+ return;
+ }
+ if (!(variableElement.getEnclosingElement() instanceof TypeElement enclosingTypeElement)) {
+ messager.printMessage(ERROR, "@ConfigurationParameter annotated element did not have an enclosing element",
+ element);
+ return;
+ }
+ var annotationMirror = requireNonNull(getAnnotationMirror(element, ConfigurationParameter.class));
+ var field = new ConfigurationParameterAnnotatedField(variableElement, elementUtils, enclosingTypeElement,
+ annotationMirror);
+ if (!field.isStatic() || !field.isFinal() || !(field.constantValue() instanceof String name)) {
+ messager.printMessage(ERROR,
+ "@ConfigurationParameter annotated field must static, final, and have constant string value", element);
+ return;
+ }
+ var description = processDescription(field);
+ var sourceType = processSourceType(field);
+ var defaults = processDefaults(field);
+ var deprecation = processDeprecation(field);
+ var defaultType = defaults == null ? null : defaults.defaultType();
+ var defaultValue = defaults == null ? null : defaults.value();
+ var type = processType(field, defaultType);
+ var property = new Property(name, type, description, sourceType, defaultValue, deprecation);
+ metaData.addProperty(property);
+ }
+
+ private @Nullable String processType(ConfigurationParameterAnnotatedField field, @Nullable String defaultType) {
+ var type = field.typeValue();
+ return type == null ? defaultType : type;
+ }
+
+ private @Nullable String processDescription(ConfigurationParameterAnnotatedField field) {
+ var docComment = field.docComment();
+ if (docComment == null) {
+ return null;
+ }
+ // TODO: Creating patterns over and over is not very efficient
+ // TODO: Handle {@link ...}, check how does Spring do that?
+ var matcher = Pattern.compile("|").matcher(docComment);
+ var firstParagraph = !matcher.find() ? docComment : docComment.substring(0, matcher.start());
+ return firstParagraph //
+ // Replace newlines with space
+ .replaceAll("[\n\r]", " ") //
+ // Merge multiple spaces
+ .replaceAll(" +", " ") //
+ // Replace the `: {@value}` conventional syntax.
+ .replaceAll(": \\{@value}\\.?", ".") //
+ .trim();
+ }
+
+ private String processSourceType(ConfigurationParameterAnnotatedField field) {
+ return field.enclosingTypeName();
+ }
+
+ private @Nullable Default processDefaults(ConfigurationParameterAnnotatedField field) {
+ var defaultValues = field.defaultValues();
+ if (defaultValues.isEmpty()) {
+ return null;
+ }
+ if (defaultValues.size() != 1) {
+ messager.printMessage(ERROR, "@ConfigurationParameter must have exactly one default value", field.element(),
+ field.annotationMirror());
+ }
+ var entry = defaultValues.entrySet().iterator().next();
+ var value = entry.getValue();
+ if (value.isEmpty()) {
+ return null;
+ }
+ if (value.size() != 1) {
+ messager.printMessage(ERROR, "@ConfigurationParameter must have exactly one default value", field.element(),
+ field.annotationMirror());
+ }
+ var defaultValue = value.get(0);
+ var defaultName = entry.getKey();
+ var defaultType = requireNonNull(NAME_TO_TYPE_NAME.get(defaultName));
+ return new Default(defaultType, defaultValue);
+ }
+
+ private record Default(String defaultType, Object value) {
+
+ }
+
+ private @Nullable Deprecation processDeprecation(ConfigurationParameterAnnotatedField field) {
+ var values = field.deprecationValues();
+ if (!values.isEmpty()) {
+ return new Deprecation(null, values.get("reason"), values.get("replacement"), values.get("since"));
+ }
+ // Fallback, look for @Deprecated
+ if (field.isDeprecated()) {
+ return new Deprecation(null, null, null, null);
+ }
+ return null;
+ }
+
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/JsonConverter.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/JsonConverter.java
new file mode 100644
index 000000000000..766c1781a793
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/JsonConverter.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.junit.platform.configuration.processor.ConfigurationMetaData.Deprecation;
+import org.junit.platform.configuration.processor.ConfigurationMetaData.Deprecation.Level;
+import org.junit.platform.configuration.processor.ConfigurationMetaData.Property;
+
+import jakarta.json.Json;
+import jakarta.json.JsonArray;
+import jakarta.json.JsonBuilderFactory;
+import jakarta.json.JsonObject;
+import jakarta.json.JsonObjectBuilder;
+import jakarta.json.JsonValue;
+
+final class JsonConverter {
+ private final Map config = Map.of();
+ private final JsonBuilderFactory factory = Json.createBuilderFactory(config);
+
+ JsonObject toJsonObject(ConfigurationMetaData metaData) {
+ var builder = factory.createObjectBuilder();
+
+ var properties = metaData.properties();
+ if (!properties.isEmpty()) {
+ builder.add("properties", toJsonArray(properties, this::toJsonObject));
+ }
+
+ return builder.build();
+ }
+
+ private JsonObject toJsonObject(Property property) {
+ var builder = factory.createObjectBuilder();
+ builder.add("name", property.name());
+
+ var type = property.type();
+ if (type != null) {
+ builder.add("type", type);
+ }
+
+ var description = property.description();
+ if (description != null) {
+ builder.add("description", description);
+ }
+
+ var sourceType = property.sourceType();
+ if (sourceType != null) {
+ builder.add("sourceType", sourceType);
+ }
+
+ var defaultValue = property.defaultValue();
+ if (defaultValue != null) {
+ addObjectValue(builder, "defaultValue", defaultValue);
+ }
+
+ var deprecation = property.deprecation();
+ if (deprecation != null) {
+ builder.add("deprecation", toJsonObject(deprecation));
+ }
+
+ return builder.build();
+ }
+
+ private void addObjectValue(JsonObjectBuilder builder, String name, Object defaultValue) {
+ if (defaultValue instanceof Short v) {
+ builder.add(name, v);
+ }
+ else if (defaultValue instanceof Byte v) {
+ builder.add(name, "%02X".formatted(v));
+ }
+ else if (defaultValue instanceof Integer v) {
+ builder.add(name, v);
+ }
+ else if (defaultValue instanceof Long v) {
+ builder.add(name, v);
+ }
+ else if (defaultValue instanceof Float v) {
+ builder.add(name, v);
+ }
+ else if (defaultValue instanceof Double v) {
+ builder.add(name, v);
+ }
+ else if (defaultValue instanceof Character v) {
+ builder.add(name, String.valueOf(v));
+ }
+ else if (defaultValue instanceof Boolean v) {
+ builder.add(name, v);
+ }
+ else if (defaultValue instanceof String v) {
+ builder.add(name, v);
+ }
+ else {
+ builder.add(name, defaultValue.toString());
+ }
+ }
+
+ private JsonObject toJsonObject(Deprecation deprecation) {
+ var builder = factory.createObjectBuilder();
+
+ var level = deprecation.level();
+ if (level != null) {
+ builder.add("level", toJsonValue(level));
+ }
+
+ var reason = deprecation.reason();
+ if (reason != null) {
+ builder.add("reason", reason);
+ }
+
+ var replacement = deprecation.replacement();
+ if (replacement != null) {
+ builder.add("replacement", replacement);
+ }
+
+ var since = deprecation.since();
+ if (since != null) {
+ builder.add("since", since);
+ }
+
+ return builder.build();
+ }
+
+ private String toJsonValue(Level level) {
+ return level.value();
+ }
+
+ private JsonArray toJsonArray(List properties, Function converter) {
+ var builder = factory.createArrayBuilder();
+ properties.forEach(element -> builder.add(converter.apply(element)));
+ return builder.build();
+ }
+}
diff --git a/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/package-info.java b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/package-info.java
new file mode 100644
index 000000000000..77966dcca659
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/java/org/junit/platform/configuration/processor/package-info.java
@@ -0,0 +1,14 @@
+/*
+ * Copyright 2012 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+@NullMarked
+package org.junit.platform.configuration.processor;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/junit-platform-configuration-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors b/junit-platform-configuration-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors
new file mode 100644
index 000000000000..3019ff07037d
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors
@@ -0,0 +1 @@
+org.junit.platform.configuration.processor.ConfigurationMetadataAnnotationProcessor,aggregating
diff --git a/junit-platform-configuration-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/junit-platform-configuration-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor
new file mode 100644
index 000000000000..c034bf8213af
--- /dev/null
+++ b/junit-platform-configuration-processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor
@@ -0,0 +1 @@
+org.junit.platform.configuration.processor.ConfigurationMetadataAnnotationProcessor
diff --git a/junit-platform-configuration-processor/src/test/README.md b/junit-platform-configuration-processor/src/test/README.md
new file mode 100644
index 000000000000..6e2fd0b363f0
--- /dev/null
+++ b/junit-platform-configuration-processor/src/test/README.md
@@ -0,0 +1 @@
+For compatibility with the Eclipse IDE, the test for this module are in the `platform-tests` project.
diff --git a/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts b/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts
index c0bfd2391dff..31bd2d475ccf 100644
--- a/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts
+++ b/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts
@@ -1,5 +1,6 @@
import junitbuild.extensions.withArchiveOperations
import junitbuild.java.WriteArtifactsFile
+import junitbuild.shadow.ConfigurationMetadataMergingTransformer
plugins {
id("junitbuild.java-library-conventions")
@@ -58,6 +59,9 @@ tasks {
// https://github.com/junit-team/junit-framework/issues/2557
// exclude compiled module declarations from any source (e.g. /*, /META-INF/versions/N/*)
exclude("**/module-info.class")
+ transform(ConfigurationMetadataMergingTransformer::class.java) {
+ resource = "META-INF/junit-platform-configuration-metadata.json"
+ }
// https://github.com/junit-team/junit-framework/issues/761
// prevent duplicates, add 3rd-party licenses explicitly
exclude("**/COPYRIGHT*")
diff --git a/junit-platform-launcher/junit-platform-launcher.gradle.kts b/junit-platform-launcher/junit-platform-launcher.gradle.kts
index 00e431dead15..046bd921df5a 100644
--- a/junit-platform-launcher/junit-platform-launcher.gradle.kts
+++ b/junit-platform-launcher/junit-platform-launcher.gradle.kts
@@ -6,12 +6,16 @@ plugins {
description = "JUnit Platform Launcher"
dependencies {
+ annotationProcessor(projects.junitPlatformConfigurationProcessor)
+
api(platform(projects.junitBom))
api(projects.junitPlatformEngine)
compileOnlyApi(libs.apiguardian)
compileOnlyApi(libs.jspecify)
+ compileOnly(projects.junitPlatformConfigurationApi)
+
osgiVerification(projects.junitJupiterEngine)
}
@@ -20,6 +24,9 @@ javadocConventions {
}
tasks {
+ compileJava {
+ options.compilerArgs.add("-Xlint:-processing") // -processing: not all annotations need to be processed
+ }
jar {
bundle {
bnd("""
diff --git a/junit-platform-launcher/src/main/java/module-info.java b/junit-platform-launcher/src/main/java/module-info.java
index 466aa808fc1c..93c1672cfbf1 100644
--- a/junit-platform-launcher/src/main/java/module-info.java
+++ b/junit-platform-launcher/src/main/java/module-info.java
@@ -25,6 +25,8 @@
requires static transitive org.apiguardian.api;
requires static transitive org.jspecify;
+
+ requires static org.junit.platform.configuration.api;
requires static jdk.jfr;
requires transitive java.logging;
diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java
index de1cfc719f78..9fc74b42932d 100644
--- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java
+++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/LauncherConstants.java
@@ -16,6 +16,9 @@
import org.apiguardian.api.API;
import org.junit.platform.commons.util.ClassNamePatternFilterUtils;
+import org.junit.platform.configuration.api.ConfigurationParameter;
+import org.junit.platform.configuration.api.ConfigurationParameter.Value;
+import org.junit.platform.engine.DiscoveryIssue.Severity;
import org.junit.platform.engine.EngineDiscoveryRequest;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.reporting.ReportEntry;
@@ -44,6 +47,7 @@ public class LauncherConstants {
* @see ReportEntry
* @see TestExecutionListener#reportingEntryPublished(TestIdentifier, ReportEntry)
*/
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String CAPTURE_STDOUT_PROPERTY_NAME = "junit.platform.output.capture.stdout";
/**
@@ -61,8 +65,17 @@ public class LauncherConstants {
* @see ReportEntry
* @see TestExecutionListener#reportingEntryPublished(TestIdentifier, ReportEntry)
*/
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String CAPTURE_STDERR_PROPERTY_NAME = "junit.platform.output.capture.stderr";
+ /**
+ * Default maximum number of bytes for buffering to use per thread and
+ * output type if output capturing is enabled.
+ *
+ * @see #CAPTURE_MAX_BUFFER_PROPERTY_NAME
+ */
+ public static final int CAPTURE_MAX_BUFFER_DEFAULT = 4 * 1024 * 1024;
+
/**
* Property name used to configure the maximum number of bytes for buffering
* to use per thread and output type if output capturing is enabled:
@@ -72,16 +85,9 @@ public class LauncherConstants {
*
* @see #CAPTURE_MAX_BUFFER_DEFAULT
*/
+ @ConfigurationParameter(defaultValue = @Value(intValue = CAPTURE_MAX_BUFFER_DEFAULT))
public static final String CAPTURE_MAX_BUFFER_PROPERTY_NAME = "junit.platform.output.capture.maxBuffer";
- /**
- * Default maximum number of bytes for buffering to use per thread and
- * output type if output capturing is enabled.
- *
- * @see #CAPTURE_MAX_BUFFER_PROPERTY_NAME
- */
- public static final int CAPTURE_MAX_BUFFER_DEFAULT = 4 * 1024 * 1024;
-
/**
* Key used to publish captured output to {@link System#out} as part of a
* {@link ReportEntry}: {@value}
@@ -139,6 +145,7 @@ public class LauncherConstants {
* @see #DEACTIVATE_ALL_LISTENERS_PATTERN
* @see org.junit.platform.launcher.TestExecutionListener
*/
+ @ConfigurationParameter(type = String.class)
public static final String DEACTIVATE_LISTENERS_PATTERN_PROPERTY_NAME = "junit.platform.execution.listeners.deactivate";
/**
@@ -166,6 +173,7 @@ public class LauncherConstants {
* @see LauncherInterceptor
*/
@API(status = MAINTAINED, since = "1.13.3")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String ENABLE_LAUNCHER_INTERCEPTORS = "junit.platform.launcher.interceptors.enabled";
/**
@@ -182,6 +190,7 @@ public class LauncherConstants {
* Value must be either {@code true} or {@code false}; defaults to {@code false}.
*/
@API(status = MAINTAINED, since = "1.13.3")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String DRY_RUN_PROPERTY_NAME = "junit.platform.execution.dryRun.enabled";
/**
@@ -192,6 +201,7 @@ public class LauncherConstants {
* @see org.junit.platform.launcher.core.EngineExecutionOrchestrator
*/
@API(status = MAINTAINED, since = "1.13.3")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = true))
public static final String STACKTRACE_PRUNING_ENABLED_PROPERTY_NAME = "junit.platform.stacktrace.pruning.enabled";
/**
@@ -208,6 +218,7 @@ public class LauncherConstants {
* @see TestPlan#getOutputDirectoryCreator()
*/
@API(status = MAINTAINED, since = "1.13.3")
+ @ConfigurationParameter(type = String.class)
public static final String OUTPUT_DIR_PROPERTY_NAME = "junit.platform.reporting.output.dir";
/**
@@ -248,6 +259,7 @@ public class LauncherConstants {
* @see org.junit.platform.engine.DiscoveryIssue.Severity
*/
@API(status = EXPERIMENTAL, since = "6.0")
+ @ConfigurationParameter(type = Severity.class, defaultValue = @Value(stringValue = "error"))
public static final String CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME = "junit.platform.discovery.issue.severity.critical";
/**
@@ -268,6 +280,9 @@ public class LauncherConstants {
* @see #CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME
*/
@API(status = EXPERIMENTAL, since = "6.0")
+ // TODO: LauncherPhase is not part of the public API
+ // TODO: Reference enum values as strings?
+ @ConfigurationParameter(/*type = LauncherPhase.class,*/ defaultValue = @Value(stringValue = "discovery"))
public static final String DISCOVERY_ISSUE_FAILURE_PHASE_PROPERTY_NAME = "junit.platform.discovery.issue.failure.phase";
/**
@@ -294,6 +309,7 @@ public class LauncherConstants {
* @see #MEMORY_CLEANUP_EXCLUDED_ENGINES_PROPERTY_NAME
*/
@API(status = EXPERIMENTAL, since = "6.1")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String MEMORY_CLEANUP_ENABLED_PROPERTY_NAME = "junit.platform.execution.memory.cleanup.enabled";
/**
@@ -316,6 +332,7 @@ public class LauncherConstants {
* @see #MEMORY_CLEANUP_ENABLED_PROPERTY_NAME
*/
@API(status = EXPERIMENTAL, since = "6.1.1")
+ @ConfigurationParameter(type = String.class)
public static final String MEMORY_CLEANUP_EXCLUDED_ENGINES_PROPERTY_NAME = "junit.platform.execution.memory.cleanup.engines.excluded";
private LauncherConstants() {
diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentChecker.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentChecker.java
index 4ddc74c8128c..14efcc0ada2f 100644
--- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentChecker.java
+++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentChecker.java
@@ -37,6 +37,8 @@ class ClasspathAlignmentChecker {
"org.junit.jupiter.params", //
"org.junit.platform.commons", //
"org.junit.platform.console", //
+ "org.junit.platform.configuration.api", //
+ "org.junit.platform.configuration.processor", //
"org.junit.platform.engine", //
"org.junit.platform.launcher", //
"org.junit.platform.reporting", //
diff --git a/junit-vintage-engine/junit-vintage-engine.gradle.kts b/junit-vintage-engine/junit-vintage-engine.gradle.kts
index d8b3f7cc3b22..315dbd526f42 100644
--- a/junit-vintage-engine/junit-vintage-engine.gradle.kts
+++ b/junit-vintage-engine/junit-vintage-engine.gradle.kts
@@ -9,6 +9,8 @@ plugins {
description = "JUnit Vintage Engine"
dependencies {
+ annotationProcessor(projects.junitPlatformConfigurationProcessor)
+
api(platform(projects.junitBom))
api(projects.junitPlatformEngine)
api(libs.junit4)
@@ -16,6 +18,8 @@ dependencies {
compileOnlyApi(libs.apiguardian)
compileOnlyApi(libs.jspecify)
+ compileOnly(projects.junitPlatformConfigurationApi)
+
testFixturesApi(platform(libs.groovy2.bom))
testFixturesApi(libs.spock1)
testFixturesImplementation(projects.junitPlatformSuiteApi)
@@ -33,7 +37,7 @@ dependencies {
tasks {
compileJava {
- options.compilerArgs.add("-Xlint:-requires-automatic") // JUnit 4
+ options.compilerArgs.add("-Xlint:-requires-automatic,-processing") // -requires-automatic: JUnit 4, -module: due to qualified exports, -processing: not all annotations need to be processed
}
compileTestFixturesGroovy {
javaLauncher = project.javaToolchains.launcherFor {
diff --git a/junit-vintage-engine/src/main/java/module-info.java b/junit-vintage-engine/src/main/java/module-info.java
index 336418b4cf0a..eecadbee0850 100644
--- a/junit-vintage-engine/src/main/java/module-info.java
+++ b/junit-vintage-engine/src/main/java/module-info.java
@@ -20,6 +20,7 @@
module org.junit.vintage.engine {
requires static org.apiguardian.api;
+ requires static org.junit.platform.configuration.api;
requires static transitive org.jspecify;
requires junit; // 4
diff --git a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java
index 2c7da30e392a..061fa4528599 100644
--- a/junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java
+++ b/junit-vintage-engine/src/main/java/org/junit/vintage/engine/Constants.java
@@ -14,6 +14,8 @@
import static org.apiguardian.api.API.Status.MAINTAINED;
import org.apiguardian.api.API;
+import org.junit.platform.configuration.api.ConfigurationParameter;
+import org.junit.platform.configuration.api.ConfigurationParameter.Value;
/**
* Collection of constants related to the {@link VintageTestEngine}.
@@ -34,6 +36,7 @@ public final class Constants {
* @since 5.12
*/
@API(status = MAINTAINED, since = "5.13.3")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String PARALLEL_EXECUTION_ENABLED = "junit.vintage.execution.parallel.enabled";
/**
@@ -46,6 +49,7 @@ public final class Constants {
* @since 5.12
*/
@API(status = MAINTAINED, since = "5.13.3")
+ @ConfigurationParameter(type = Integer.class)
public static final String PARALLEL_POOL_SIZE = "junit.vintage.execution.parallel.pool-size";
/**
@@ -58,6 +62,7 @@ public final class Constants {
* @since 5.12
*/
@API(status = MAINTAINED, since = "5.13.3")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String PARALLEL_CLASS_EXECUTION = "junit.vintage.execution.parallel.classes";
/**
@@ -70,6 +75,7 @@ public final class Constants {
* @since 5.12
*/
@API(status = MAINTAINED, since = "5.13.3")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = false))
public static final String PARALLEL_METHOD_EXECUTION = "junit.vintage.execution.parallel.methods";
/**
@@ -82,6 +88,7 @@ public final class Constants {
* @since 6.0.1
*/
@API(status = MAINTAINED, since = "6.0.1")
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = true))
public static final String DISCOVERY_ISSUE_REPORTING_ENABLED_PROPERTY_NAME = "junit.vintage.discovery.issue.reporting.enabled";
private Constants() {
diff --git a/platform-tests/platform-tests.gradle.kts b/platform-tests/platform-tests.gradle.kts
index 2d6c6c11d3f3..d6bc09ce80eb 100644
--- a/platform-tests/platform-tests.gradle.kts
+++ b/platform-tests/platform-tests.gradle.kts
@@ -33,6 +33,7 @@ val woodstoxRuntimeClasspath = configurations.resolvable("woodstoxRuntimeClasspa
dependencies {
// --- Things we are testing --------------------------------------------------
testImplementation(projects.junitPlatformCommons)
+ testImplementation(projects.junitPlatformConfigurationProcessor)
testImplementation(projects.junitPlatformConsole)
testImplementation(projects.junitPlatformEngine)
testImplementation(projects.junitPlatformLauncher)
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessorTests.java b/platform-tests/src/test/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessorTests.java
new file mode 100644
index 000000000000..b0b00534c57c
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/processor/ConfigurationMetadataAnnotationProcessorTests.java
@@ -0,0 +1,364 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
+
+import org.assertj.core.api.ThrowableAssert;
+import org.intellij.lang.annotations.Language;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.platform.commons.PreconditionViolationException;
+import org.junit.platform.configuration.testcases.DefaultDifferentSets;
+import org.junit.platform.configuration.testcases.DefaultMultipleValues;
+import org.junit.platform.configuration.testcases.Defaults;
+import org.junit.platform.configuration.testcases.Deprecation;
+import org.junit.platform.configuration.testcases.DeprecationWithDetails;
+import org.junit.platform.configuration.testcases.Documented;
+import org.junit.platform.configuration.testcases.DocumentedWithAtValue;
+import org.junit.platform.configuration.testcases.DocumentedWithHeader;
+import org.junit.platform.configuration.testcases.DocumentedWithMultiLines;
+import org.junit.platform.configuration.testcases.DocumentedWithMultipleParagraphs;
+import org.junit.platform.configuration.testcases.Minimal;
+import org.junit.platform.configuration.testcases.NonFinal;
+import org.junit.platform.configuration.testcases.NonStatic;
+import org.junit.platform.configuration.testcases.NonString;
+import org.junit.platform.configuration.testcases.TypeEnumWithStringDefault;
+import org.junit.platform.configuration.testcases.TypeString;
+import org.junit.platform.configuration.testcases.Without;
+
+class ConfigurationMetadataAnnotationProcessorTests {
+
+ final String expectedMetadataPath = "META-INF/junit-platform-configuration-metadata.json";
+
+ final Path sourceDirectory = Path.of("src/test/java");
+
+ @Nested
+ class ConfigurationParameter {
+
+ @TempDir
+ Path outputDirectory;
+
+ TestCompiler compiler;
+
+ @BeforeEach
+ void setup() {
+ var processor = new ConfigurationMetadataAnnotationProcessor();
+ compiler = new TestCompiler(sourceDirectory, outputDirectory, processor);
+ }
+
+ @Test
+ void none() {
+ compiler.compileWithoutError(Without.class);
+ var metaDataPath = outputDirectory.resolve(expectedMetadataPath);
+ assertThat(metaDataPath).doesNotExist();
+ }
+
+ @Test
+ void minimal() {
+ compiler.compileWithoutError(Minimal.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "sourceType": "org.junit.platform.configuration.testcases.Minimal"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void documented() {
+ compiler.compileWithoutError(Documented.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "description": "A brief description of this property.",
+ "sourceType": "org.junit.platform.configuration.testcases.Documented"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void documentedWithAtValue() {
+ compiler.compileWithoutError(DocumentedWithAtValue.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "description": "A brief description of this property.",
+ "sourceType": "org.junit.platform.configuration.testcases.DocumentedWithAtValue"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void documentedWithMultipleLines() {
+ compiler.compileWithoutError(DocumentedWithMultiLines.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "description": "A brief multi-line description of this property.",
+ "sourceType": "org.junit.platform.configuration.testcases.DocumentedWithMultiLines"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void documentedWithMultipleParagraphs() {
+ compiler.compileWithoutError(DocumentedWithMultipleParagraphs.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "description": "A brief description of this property.",
+ "sourceType": "org.junit.platform.configuration.testcases.DocumentedWithMultipleParagraphs"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void documentedWithHeader() {
+ compiler.compileWithoutError(DocumentedWithHeader.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "description": "A brief description of this property.",
+ "sourceType": "org.junit.platform.configuration.testcases.DocumentedWithHeader"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void deprecation() {
+ // TODO: Class level? Inheritance? Meta?
+ // TODO: Warning level?
+ compiler.compileWithoutError(Deprecation.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "sourceType": "org.junit.platform.configuration.testcases.Deprecation",
+ "deprecation": { }
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void deprecationWithDetails() {
+ compiler.compileWithoutError(DeprecationWithDetails.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "sourceType": "org.junit.platform.configuration.testcases.DeprecationWithDetails",
+ "deprecation": {
+ "reason": "This property was migrated to com.example",
+ "replacement": "com.example.property",
+ "since":"2.0.0"
+ }
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void stringType() {
+ compiler.compileWithoutError(TypeString.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "type": "java.lang.String",
+ "sourceType": "org.junit.platform.configuration.testcases.TypeString"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void enumTypeWithStringDefault() {
+ compiler.compileWithoutError(TypeEnumWithStringDefault.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.property",
+ "type": "org.junit.platform.configuration.testcases.TypeEnumWithStringDefault.ExampleEnum",
+ "sourceType": "org.junit.platform.configuration.testcases.TypeEnumWithStringDefault",
+ "defaultValue": "A"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void defaults() {
+ compiler.compileWithoutError(Defaults.class);
+ assertMetaDataIsEqualTo("""
+ {
+ "properties": [
+ {
+ "name": "org.example.shorts",
+ "type": "java.lang.Short",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": 1
+ },
+ {
+ "name": "org.example.bytes",
+ "type": "java.lang.Byte",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": "2A"
+ },
+ {
+ "name": "org.example.ints",
+ "type": "java.lang.Integer",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": 42
+ },
+ {
+ "name": "org.example.longs",
+ "type": "java.lang.Long",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": 42
+ },
+ {
+ "name": "org.example.floats",
+ "type": "java.lang.Float",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": 42.0
+ },
+ {
+ "name": "org.example.doubles",
+ "type": "java.lang.Double",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": 42.0
+ },
+ {
+ "name": "org.example.chars",
+ "type": "java.lang.Character",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": "4"
+ },
+ {
+ "name": "org.example.booleans",
+ "type": "java.lang.Boolean",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": true
+ },
+ {
+ "name": "org.example.strings",
+ "type": "java.lang.String",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": "default"
+ },
+ {
+ "name": "org.example.classes",
+ "type": "java.lang.Class",
+ "sourceType": "org.junit.platform.configuration.testcases.Defaults",
+ "defaultValue": "org.junit.platform.configuration.testcases.Defaults.Example"
+ }
+ ]
+ }""");
+ }
+
+ @Test
+ void mustBeFinal() {
+ var result = compiler.compile(NonFinal.class);
+ assertThat(result.diagnostics()) //
+ .extracting(diagnostic -> diagnostic.getMessage(Locale.ROOT)) //
+ .contains(
+ "@ConfigurationParameter annotated field must static, final, and have constant string value");
+ }
+
+ @Test
+ void mustBeStatic() {
+ var result = compiler.compile(NonStatic.class);
+ assertThat(result.diagnostics()) //
+ .extracting(diagnostic -> diagnostic.getMessage(Locale.ROOT)) //
+ .contains(
+ "@ConfigurationParameter annotated field must static, final, and have constant string value");
+ }
+
+ @Test
+ void mustBeString() {
+ var result = compiler.compile(NonString.class);
+ assertThat(result.diagnostics()) //
+ .extracting(diagnostic -> diagnostic.getMessage(Locale.ROOT)) //
+ .contains(
+ "@ConfigurationParameter annotated field must static, final, and have constant string value");
+ }
+
+ @Test
+ void mustHaveExactlyOneSetOfDefaults() {
+ var result = compiler.compile(DefaultDifferentSets.class);
+ assertThat(result.diagnostics()) //
+ .extracting(diagnostic -> diagnostic.getMessage(Locale.ROOT)) //
+ .contains("@ConfigurationParameter must have exactly one default value");
+ }
+
+ @Test
+ void mustHaveExactlyOneDefaultValue() {
+ var result = compiler.compile(DefaultMultipleValues.class);
+ assertThat(result.diagnostics()) //
+ .extracting(diagnostic -> diagnostic.getMessage(Locale.ROOT)) //
+ .contains("@ConfigurationParameter must have exactly one default value");
+ }
+
+ private void assertMetaDataIsEqualTo(@Language("JSON") String json) {
+ assertThat(metaData()).isEqualToIgnoringWhitespace(json);
+ }
+
+ private String metaData() throws UncheckedIOException {
+ try {
+ var metaDataPath = outputDirectory.resolve(expectedMetadataPath);
+ return Files.readString(metaDataPath);
+ }
+ catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+ }
+
+ private static void asserPreconditionViolation(ThrowableAssert.ThrowingCallable throwingCallable, String message) {
+ assertThatThrownBy(throwingCallable) //
+ .hasRootCauseExactlyInstanceOf(PreconditionViolationException.class) //
+ .hasRootCauseMessage(message);
+ }
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/processor/TestCompiler.java b/platform-tests/src/test/java/org/junit/platform/configuration/processor/TestCompiler.java
new file mode 100644
index 000000000000..f619ccbf1e46
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/processor/TestCompiler.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.processor;
+
+import static org.assertj.core.api.Assertions.fail;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.lang.reflect.Type;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import javax.annotation.processing.Processor;
+import javax.tools.Diagnostic;
+import javax.tools.DiagnosticCollector;
+import javax.tools.JavaCompiler;
+import javax.tools.SimpleJavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.ToolProvider;
+
+class TestCompiler {
+
+ private final Path sourceDirectory;
+ // TODO: Use an in memory solution, faster
+ private final Path outputDirectory;
+ private final Processor processor;
+
+ TestCompiler(Path sourceDirectory, Path outputDirectory, Processor processor) {
+ this.sourceDirectory = sourceDirectory;
+ this.outputDirectory = outputDirectory;
+ this.processor = processor;
+ }
+
+ void compileWithoutError(Type type) {
+ var result = compile(type);
+ var diagnostics = result.diagnostics();
+ if (result.success() && diagnostics.isEmpty()) {
+ return;
+ }
+ fail("""
+ Compilation of %s was not successful.
+
+ Javac output:
+
+ %s
+
+ Diagnostics:
+
+ %s
+ """.formatted(type, //
+ result.additionalOutput(), //
+ diagnostics.stream() //
+ .map(Objects::toString) //
+ .collect(Collectors.joining("\n"))));
+ }
+
+ CompilationResult compile(Type type) {
+ var options = List.of("-d", outputDirectory.toString());
+ var listener = new DiagnosticCollector<>();
+ var additionalOutput = new StringWriter();
+ var task = ToolProvider.getSystemJavaCompiler().getTask( //
+ additionalOutput, //
+ fileManagerOf(ToolProvider.getSystemJavaCompiler(), listener), //
+ listener, //
+ options, //
+ classesOf(type), //
+ compilationUnitOf(type) //
+ );
+ task.setProcessors(Set.of(processor));
+ var result = task.call();
+ return new CompilationResult(result, listener.getDiagnostics(), additionalOutput.toString());
+ }
+
+ record CompilationResult(boolean success, List> diagnostics, String additionalOutput) {
+
+ }
+
+ private static StandardJavaFileManager fileManagerOf(JavaCompiler compiler, DiagnosticCollector listener) {
+ return compiler.getStandardFileManager(listener, Locale.ROOT, Charset.defaultCharset());
+ }
+
+ private static Set classesOf(Type type) {
+ return Set.of(type.getTypeName());
+ }
+
+ private Set compilationUnitOf(Type type) {
+ var resourceName = type.getTypeName().replace('.', '/');
+ var javaFileName = resourceName + ".java";
+ var resolvedJavaFileName = sourceDirectory.resolve(javaFileName);
+ return Set.of(new JavaSourceFileObject(resolvedJavaFileName));
+ }
+
+ private static class JavaSourceFileObject extends SimpleJavaFileObject {
+
+ private final Path sourceFile;
+
+ JavaSourceFileObject(Path sourceFile) {
+ super(sourceFile.toUri(), Kind.SOURCE);
+ this.sourceFile = sourceFile;
+ }
+
+ @Override
+ public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
+ return Files.readString(sourceFile);
+ }
+ }
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DefaultDifferentSets.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DefaultDifferentSets.java
new file mode 100644
index 000000000000..cc80cd29f008
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DefaultDifferentSets.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+import org.junit.platform.configuration.api.ConfigurationParameter.Value;
+
+public final class DefaultDifferentSets {
+
+ @ConfigurationParameter(defaultValue = @Value(stringValue = "default", intValue = 42))
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DefaultMultipleValues.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DefaultMultipleValues.java
new file mode 100644
index 000000000000..712a5a9b751a
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DefaultMultipleValues.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+import org.junit.platform.configuration.api.ConfigurationParameter.Value;
+
+public final class DefaultMultipleValues {
+
+ @ConfigurationParameter(defaultValue = @Value(stringValue = { "default", "another-default" }))
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Defaults.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Defaults.java
new file mode 100644
index 000000000000..9be84a44c1af
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Defaults.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+import org.junit.platform.configuration.api.ConfigurationParameter.Value;
+
+public final class Defaults {
+
+ @ConfigurationParameter(defaultValue = @Value(shortValue = 1))
+ public static final String SHORTS_PROPERTY_NAME = "org.example.shorts";
+
+ @ConfigurationParameter(defaultValue = @Value(byteValue = 0x2A))
+ public static final String BYTES_PROPERTY_NAME = "org.example.bytes";
+
+ @ConfigurationParameter(defaultValue = @Value(intValue = 42))
+ public static final String INTS_PROPERTY_NAME = "org.example.ints";
+
+ @ConfigurationParameter(defaultValue = @Value(longValue = 42))
+ public static final String LONGS_PROPERTY_NAME = "org.example.longs";
+
+ @ConfigurationParameter(defaultValue = @Value(floatValue = 42.0f))
+ public static final String FLOATS_PROPERTY_NAME = "org.example.floats";
+
+ @ConfigurationParameter(defaultValue = @Value(doubleValue = 42.0))
+ public static final String DOUBLES_PROPERTY_NAME = "org.example.doubles";
+
+ @ConfigurationParameter(defaultValue = @Value(charValue = '4'))
+ public static final String CHARS_PROPERTY_NAME = "org.example.chars";
+
+ @ConfigurationParameter(defaultValue = @Value(booleanValue = true))
+ public static final String BOOLEANS_PROPERTY_NAME = "org.example.booleans";
+
+ @ConfigurationParameter(defaultValue = @Value(stringValue = "default"))
+ public static final String STRING_PROPERTY_NAME = "org.example.strings";
+
+ @ConfigurationParameter(defaultValue = @Value(classValue = Example.class))
+ public static final String CLASSES_PROPERTY_NAME = "org.example.classes";
+
+ private record Example() {
+
+ }
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Deprecation.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Deprecation.java
new file mode 100644
index 000000000000..43a154b650c7
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Deprecation.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class Deprecation {
+
+ @Deprecated
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DeprecationWithDetails.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DeprecationWithDetails.java
new file mode 100644
index 000000000000..11046d1c0d1f
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DeprecationWithDetails.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class DeprecationWithDetails {
+
+ @ConfigurationParameter(deprecation = @ConfigurationParameter.Deprecation(reason = "This property was migrated to com.example", replacement = "com.example.property", since = "2.0.0"))
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Documented.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Documented.java
new file mode 100644
index 000000000000..22030ba239b8
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Documented.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class Documented {
+
+ /**
+ * A brief description of this property.
+ */
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithAtValue.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithAtValue.java
new file mode 100644
index 000000000000..1d4c87e7be93
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithAtValue.java
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class DocumentedWithAtValue {
+
+ /**
+ * A brief description of this property: {@value}.
+ */
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithHeader.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithHeader.java
new file mode 100644
index 000000000000..be494189ce7b
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithHeader.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class DocumentedWithHeader {
+
+ /**
+ * A brief description of this property.
+ *
+ * Examples
+ *
+ * Followed by an additional paragraph.
+ */
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithMultiLines.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithMultiLines.java
new file mode 100644
index 000000000000..c13fc030d97c
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithMultiLines.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class DocumentedWithMultiLines {
+
+ /**
+ * A brief multi-line description of
+ * this property: {@value}.
+ */
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithMultipleParagraphs.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithMultipleParagraphs.java
new file mode 100644
index 000000000000..1ff43878c2fb
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/DocumentedWithMultipleParagraphs.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class DocumentedWithMultipleParagraphs {
+
+ /**
+ * A brief description of this property.
+ *
+ * Followed by an additional paragraph.
+ */
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Minimal.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Minimal.java
new file mode 100644
index 000000000000..5f7d32fe8669
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Minimal.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class Minimal {
+
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonFinal.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonFinal.java
new file mode 100644
index 000000000000..4c394e95274d
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonFinal.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class NonFinal {
+
+ @ConfigurationParameter
+ public static String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonStatic.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonStatic.java
new file mode 100644
index 000000000000..f9b9d1ab017b
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonStatic.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class NonStatic {
+
+ @ConfigurationParameter
+ public final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonString.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonString.java
new file mode 100644
index 000000000000..7089a30ebaa4
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/NonString.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class NonString {
+
+ @ConfigurationParameter
+ public static final Boolean EXAMPLE_PROPERTY_NAME = true;
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/TypeEnumWithStringDefault.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/TypeEnumWithStringDefault.java
new file mode 100644
index 000000000000..d6387c89a40c
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/TypeEnumWithStringDefault.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+import org.junit.platform.configuration.api.ConfigurationParameter.Value;
+
+public final class TypeEnumWithStringDefault {
+
+ @ConfigurationParameter(type = ExampleEnum.class, defaultValue = @Value(stringValue = "A"))
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+ enum ExampleEnum {
+ A, B
+ }
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/TypeString.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/TypeString.java
new file mode 100644
index 000000000000..62db3ca4d50c
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/TypeString.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class TypeString {
+
+ @ConfigurationParameter(type = String.class)
+ public static final String EXAMPLE_PROPERTY_NAME = "org.example.property";
+
+}
diff --git a/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Without.java b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Without.java
new file mode 100644
index 000000000000..3e86676e8fc0
--- /dev/null
+++ b/platform-tests/src/test/java/org/junit/platform/configuration/testcases/Without.java
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.junit.platform.configuration.testcases;
+
+public final class Without {
+
+}
diff --git a/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts b/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts
index 8b78b28fd51b..0d1fc998a98a 100644
--- a/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts
+++ b/platform-tooling-support-tests/platform-tooling-support-tests.gradle.kts
@@ -92,6 +92,7 @@ dependencies {
thirdPartyJars(libs.apiguardian)
thirdPartyJars(libs.fastcsv)
thirdPartyJars(libs.hamcrest)
+ thirdPartyJars(libs.jakarta.json.api)
thirdPartyJars(libs.jimfs)
thirdPartyJars(libs.jspecify)
thirdPartyJars(kotlin("stdlib"))
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt
index 50e4a7c4f920..856a65ab6108 100644
--- a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt
@@ -12,6 +12,7 @@ requires kotlin.stdlib static
requires org.apiguardian.api static transitive
requires org.jspecify static transitive
requires org.junit.platform.commons transitive
+requires org.junit.platform.configuration.api static
requires org.opentest4j transitive
qualified exports org.junit.jupiter.api.timeout to org.junit.jupiter.engine
qualified opens org.junit.jupiter.api.condition to org.junit.platform.commons
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-configuration-api.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-configuration-api.expected.txt
new file mode 100644
index 000000000000..34741f806073
--- /dev/null
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-configuration-api.expected.txt
@@ -0,0 +1,5 @@
+org.junit.platform.configuration.api@${version} jar:file:.+/junit-platform-configuration-api-\d.+\.jar..module-info\.class
+exports org.junit.platform.configuration.api
+requires java.base mandated
+requires org.apiguardian.api static transitive
+requires org.jspecify static transitive
\ No newline at end of file
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-configuration-processor.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-configuration-processor.expected.txt
new file mode 100644
index 000000000000..b9faa0009c62
--- /dev/null
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-configuration-processor.expected.txt
@@ -0,0 +1,8 @@
+org.junit.platform.configuration.processor@${version} jar:file:.+/junit-platform-configuration-processor-\d.+\.jar..module-info\.class
+requires java.base mandated
+requires java.compiler
+requires org.apiguardian.api static transitive
+requires org.jspecify static transitive
+requires org.junit.platform.configuration.api
+provides javax.annotation.processing.Processor with org.junit.platform.configuration.processor.ConfigurationMetadataAnnotationProcessor
+contains org.junit.platform.configuration.processor
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-launcher.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-launcher.expected.txt
index 3bf55cd46f61..1253a982bf66 100644
--- a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-launcher.expected.txt
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-launcher.expected.txt
@@ -9,6 +9,7 @@ requires jdk.jfr static
requires org.apiguardian.api static transitive
requires org.jspecify static transitive
requires org.junit.platform.commons transitive
+requires org.junit.platform.configuration.api static
requires org.junit.platform.engine transitive
uses org.junit.platform.engine.TestEngine
uses org.junit.platform.launcher.LauncherDiscoveryListener
@@ -16,4 +17,4 @@ uses org.junit.platform.launcher.LauncherInterceptor
uses org.junit.platform.launcher.LauncherSessionListener
uses org.junit.platform.launcher.PostDiscoveryFilter
uses org.junit.platform.launcher.TestExecutionListener
-provides org.junit.platform.launcher.TestExecutionListener with org.junit.platform.launcher.listeners.UniqueIdTrackingListener
+provides org.junit.platform.launcher.TestExecutionListener with org.junit.platform.launcher.listeners.UniqueIdTrackingListener
\ No newline at end of file
diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-vintage-engine.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-vintage-engine.expected.txt
index 8d3bf8d6650f..ce57bcbfc2e2 100644
--- a/platform-tooling-support-tests/projects/jar-describe-module/junit-vintage-engine.expected.txt
+++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-vintage-engine.expected.txt
@@ -3,6 +3,7 @@ requires java.base mandated
requires junit
requires org.apiguardian.api static
requires org.jspecify static transitive
+requires org.junit.platform.configuration.api static
requires org.junit.platform.engine
provides org.junit.platform.engine.TestEngine with org.junit.vintage.engine.VintageTestEngine
contains org.junit.vintage.engine
diff --git a/platform-tooling-support-tests/projects/maven-junit-platform-configuration-processor/pom.xml b/platform-tooling-support-tests/projects/maven-junit-platform-configuration-processor/pom.xml
new file mode 100644
index 000000000000..9eda21a3de00
--- /dev/null
+++ b/platform-tooling-support-tests/projects/maven-junit-platform-configuration-processor/pom.xml
@@ -0,0 +1,69 @@
+
+
+ 4.0.0
+
+ org.example
+ maven-junit-platform-configuration-processor
+ 1.0-SNAPSHOT
+
+
+ UTF-8
+ 17
+
+
+
+
+ org.junit.platform
+ junit-platform-configuration-api
+ true
+
+
+
+
+
+
+ org.junit
+ junit-bom
+ ${junit.version}
+ pom
+ import
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.15.0
+
+ true
+
+
+ org.junit.platform
+ junit-platform-configuration-processor
+
+
+
+
+
+
+
+
+
+ local-temp
+ file://${maven.repo}
+
+ true
+ ignore
+
+
+ true
+ ignore
+
+
+
+
+
diff --git a/platform-tooling-support-tests/projects/maven-junit-platform-configuration-processor/src/main/java/org/example/Constants.java b/platform-tooling-support-tests/projects/maven-junit-platform-configuration-processor/src/main/java/org/example/Constants.java
new file mode 100644
index 000000000000..7356159f151c
--- /dev/null
+++ b/platform-tooling-support-tests/projects/maven-junit-platform-configuration-processor/src/main/java/org/example/Constants.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package org.example;
+
+import org.junit.platform.configuration.api.ConfigurationParameter;
+
+public final class Constants {
+
+ @ConfigurationParameter
+ public static final String EXAMPLE_PROPERTY = "org.example.property";
+}
diff --git a/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java b/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java
index 026711669ebc..2e595ea9bc6f 100644
--- a/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java
+++ b/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java
@@ -183,7 +183,8 @@ void freeOfPackageCycles(JavaClasses classes) throws Exception {
@ArchTest
void avoidJavaUtilLogging(JavaClasses classes) {
// LoggerFactory.java:80 -> sets field LoggerFactory$DelegatingLogger.julLogger
- var subset = classes.that(are(not(name("org.junit.platform.commons.logging.LoggerFactory$DelegatingLogger"))));
+ var subset = classes.that(are(not(
+ name("org.junit.platform.commons.logging.LoggerFactory$DelegatingLogger").or(nameContaining(".shadow.")))));
GeneralCodingRules.NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING.check(subset);
}
diff --git a/platform-tooling-support-tests/src/main/java/platform/tooling/support/Projects.java b/platform-tooling-support-tests/src/main/java/platform/tooling/support/Projects.java
index a29439b115e6..23284fa19b40 100644
--- a/platform-tooling-support-tests/src/main/java/platform/tooling/support/Projects.java
+++ b/platform-tooling-support-tests/src/main/java/platform/tooling/support/Projects.java
@@ -25,6 +25,7 @@ public class Projects {
public static final String JUPITER_STARTER = "jupiter-starter";
public static final String KOTLIN_COROUTINES = "kotlin-coroutines";
public static final String MAVEN_SUREFIRE_COMPATIBILITY = "maven-surefire-compatibility";
+ public static final String MAVEN_JUNIT_PLATFORM_CONFIGURATION_PROCESSOR = "maven-junit-platform-configuration-processor";
public static final String MEMORY_CLEANUP = "memory-cleanup";
public static final String REFLECTION_TESTS = "reflection-tests";
public static final String STANDALONE = "standalone";
diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java
index 54ad44834707..9a6ffdb0bb0c 100644
--- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java
+++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/HelperTests.java
@@ -38,6 +38,8 @@ void loadModuleDirectoryNames() {
"junit-jupiter-params", //
"junit-start", //
"junit-platform-commons", //
+ "junit-platform-configuration-api", //
+ "junit-platform-configuration-processor", //
"junit-platform-console", //
"junit-platform-engine", //
"junit-platform-launcher", //
diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MavenJunitPlatformConfigurationProcessorTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MavenJunitPlatformConfigurationProcessorTests.java
new file mode 100644
index 000000000000..08aa7f8da8ea
--- /dev/null
+++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MavenJunitPlatformConfigurationProcessorTests.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2015-2026 the original author or authors.
+ *
+ * All rights reserved. This program and the accompanying materials are
+ * made available under the terms of the Eclipse Public License v2.0 which
+ * accompanies this distribution and is available at
+ *
+ * https://www.eclipse.org/legal/epl-v20.html
+ */
+
+package platform.tooling.support.tests;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static platform.tooling.support.Projects.copyToWorkspace;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.platform.tests.process.OutputFiles;
+import org.opentest4j.TestAbortedException;
+
+import platform.tooling.support.FilePrefix;
+import platform.tooling.support.Helper;
+import platform.tooling.support.MavenRepo;
+import platform.tooling.support.ProcessStarters;
+import platform.tooling.support.Projects;
+
+/**
+ * @since 6.2.0
+ */
+class MavenJunitPlatformConfigurationProcessorTests {
+
+ @ManagedResource
+ LocalMavenRepo localMavenRepo;
+
+ @Test
+ void processesAnnotationsIntoMetaData(@TempDir Path workspace, @FilePrefix("maven") OutputFiles outputFiles)
+ throws Exception {
+ var result = ProcessStarters.maven(Helper.getJavaHome(17).orElseThrow(TestAbortedException::new)) //
+ .workingDir(copyToWorkspace(Projects.MAVEN_JUNIT_PLATFORM_CONFIGURATION_PROCESSOR, workspace)) //
+ .addArguments(localMavenRepo.toCliArgument(), "-Dmaven.repo=" + MavenRepo.dir()) //
+ .addArguments("--update-snapshots", "--batch-mode", "compile") //
+ .redirectOutput(outputFiles) //
+ .startAndWait();
+
+ assertEquals(0, result.exitCode());
+ assertEquals("", result.stdErr());
+
+ var output = result.stdOutLines();
+ assertTrue(output.contains("[INFO] BUILD SUCCESS"));
+
+ var metaData = workspace.resolve("target/classes/META-INF/junit-platform-configuration-metadata.json");
+ assertThat(metaData).exists().content().isEqualTo("""
+ {"properties":[{"name":"org.example.property","sourceType":"org.example.Constants"}]}""");
+ }
+}
diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/ModularCompilationTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/ModularCompilationTests.java
index 00c572a25037..687c10c171fb 100644
--- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/ModularCompilationTests.java
+++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/ModularCompilationTests.java
@@ -56,7 +56,9 @@ void compileAllJUnitModules(@TempDir Path workspace, @FilePrefix("javac") Output
.addArguments("--add-modules", "org.opentest4j.reporting.events") //
.addArguments("--add-reads", "org.junit.platform.reporting=org.opentest4j.reporting.events") //
.addArguments("--add-modules", "de.siegmar.fastcsv") //
- .addArguments("--add-reads", "org.junit.jupiter.params=de.siegmar.fastcsv")
+ .addArguments("--add-reads", "org.junit.jupiter.params=de.siegmar.fastcsv") //
+ .addArguments("--add-modules", "jakarta.json") //
+ .addArguments("--add-reads", "org.junit.platform.configuration.processor=jakarta.json")
// modules to compile
.addArguments("--module", String.join(",", moduleNames)) //
.redirectOutput(javacOutputFiles) //
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 667bfd2414af..45875604341b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -50,6 +50,8 @@ run {
includeProject("junit-jupiter-migrationsupport", mavenized = true, modular = true)
includeProject("junit-jupiter-params", mavenized = true, modular = true)
includeProject("junit-platform-commons", mavenized = true, modular = true)
+ includeProject("junit-platform-configuration-api", mavenized = true, modular = true)
+ includeProject("junit-platform-configuration-processor", mavenized = true, modular = true)
includeProject("junit-platform-console", mavenized = true, modular = true)
includeProject("junit-platform-console-standalone", mavenized = true)
includeProject("junit-platform-engine", mavenized = true, modular = true)