diff --git a/documentation/documentation.gradle.kts b/documentation/documentation.gradle.kts index 1b5641d2bcfa..dd0a6b02fe79 100644 --- a/documentation/documentation.gradle.kts +++ b/documentation/documentation.gradle.kts @@ -100,6 +100,7 @@ dependencies { javadocClasspath(libs.picocli) javadocClasspath(libs.fastcsv) javadocClasspath(libs.openTestReporting.events) + javadocClasspath(libs.jakarta.json.api) javadocClasspath(libs.kotlinx.coroutines.core) javadocClasspath(kotlin("stdlib")) javadocClasspath(kotlin("reflect")) @@ -406,10 +407,11 @@ tasks { args.addAll( "--module", modularProjects.joinToString(",") { it.javaModuleName }, - "--add-modules", "info.picocli,org.opentest4j.reporting.events,de.siegmar.fastcsv", + "--add-modules", "info.picocli,org.opentest4j.reporting.events,de.siegmar.fastcsv,jakarta.json", "--add-reads", "org.junit.platform.console=info.picocli", "--add-reads", "org.junit.platform.reporting=org.opentest4j.reporting.events", "--add-reads", "org.junit.jupiter.params=de.siegmar.fastcsv", + "--add-reads", "org.junit.platform.configuration.processor=jakarta.json", ) outputDirectory = layout.buildDirectory.dir("docs/javadoc") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97e85de4e361..84c755a3a807 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,8 @@ hamcrest = { module = "org.hamcrest:hamcrest", version = "3.0" } jackson-databind = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" } jackson-dataformat-yaml = { module = "tools.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } jackson-module-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson" } +jakarta-json-api = { module = "jakarta.json:jakarta.json-api", version = "2.1.3" } +jakarta-json-implementation = { module = "org.eclipse.parsson:parsson", version = "1.1.9" } jaxb-api = { module = "jakarta.xml.bind:jakarta.xml.bind-api", version = "4.0.5" } jaxb-runtime = { module = "org.glassfish.jaxb:jaxb-runtime", version = "4.0.9" } jfrunit = { module = "org.moditect.jfrunit:jfrunit-core", version = "1.0.0.Alpha2" } diff --git a/gradle/plugins/common/build.gradle.kts b/gradle/plugins/common/build.gradle.kts index 3426a12782cc..cbefb1397cdc 100644 --- a/gradle/plugins/common/build.gradle.kts +++ b/gradle/plugins/common/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation("junitbuild.base:build-parameters") implementation(projects.backwardCompatibility) implementation(projects.javadoc) + implementation(libs.jackson.databind) implementation(libs.plugins.kotlin.markerCoordinates) implementation(libs.plugins.bnd.markerCoordinates) implementation(libs.plugins.develocity.markerCoordinates) diff --git a/gradle/plugins/common/src/main/kotlin/junitbuild/shadow/ConfigurationMetadataMergingTransformer.kt b/gradle/plugins/common/src/main/kotlin/junitbuild/shadow/ConfigurationMetadataMergingTransformer.kt new file mode 100644 index 000000000000..aa19c0068f02 --- /dev/null +++ b/gradle/plugins/common/src/main/kotlin/junitbuild/shadow/ConfigurationMetadataMergingTransformer.kt @@ -0,0 +1,66 @@ +package junitbuild.shadow + +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import com.github.jengelman.gradle.plugins.shadow.transformers.ResourceTransformer +import com.github.jengelman.gradle.plugins.shadow.transformers.TransformerContext +import org.apache.tools.zip.ZipEntry +import org.apache.tools.zip.ZipOutputStream +import org.gradle.api.file.FileTreeElement +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import tools.jackson.databind.json.JsonMapper +import tools.jackson.databind.node.ArrayNode +import tools.jackson.databind.node.ObjectNode +import javax.inject.Inject + +/** + * Merges JUnit/Spring configuration metadata files by concatenating the array + * values of their top-level fields (`groups`, `properties`, and `hints`). + * + * @see Configuration Metadata format + */ +abstract class ConfigurationMetadataMergingTransformer @Inject constructor( + override val objectFactory: ObjectFactory +) : ResourceTransformer { + + @get:Input + abstract val resource: Property + + 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 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 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 entry) { + Object value = entry.getValue().getValue(); + return value.toString(); + } + + private static String getSimpleName(Entry 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 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)