diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java new file mode 100644 index 00000000000..36ef2803b43 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/EnabledIfRuntimeHintsAgent.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.aot.test.agent; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.aot.agent.RuntimeHintsAgent; + +/** + * {@code @EneabledIfRuntimeHintsAgent} signals that the annotated test class or test method + * is only enabled if the {@link RuntimeHintsAgent} is loaded on the current JVM. + * + *
+ * @EnabledIfRuntimeHintsAgent
+ * class MyTestCases {
+ *
+ *     @Test
+ *     void hintsForMethodsReflectionShouldMatch() {
+ *         RuntimeHints hints = new RuntimeHints();
+ *         hints.reflection().registerType(String.class,
+ *             hint -> hint.withMembers(MemberCategory.INTROSPECT_PUBLIC_METHODS));
+ *
+ *         RuntimeHintsInvocations invocations = RuntimeHintsRecorder.record(() -> {
+ *             Method[] methods = String.class.getMethods();
+ *         });
+ *         assertThat(invocations).match(hints);
+ *     }
+ *
+ * }
+ * 
+ * + * @author Brian Clozel + * @since 6.0 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ExtendWith(RuntimeHintsAgentCondition.class) +@Tag("RuntimeHintsTests") +public @interface EnabledIfRuntimeHintsAgent { + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java new file mode 100644 index 00000000000..9dfcddb7d73 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsAgentCondition.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.aot.test.agent; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +import org.springframework.aot.agent.RuntimeHintsAgent; + +import static org.junit.platform.commons.util.AnnotationUtils.findAnnotation; + +/** + * {@link ExecutionCondition} for {@link EnabledIfRuntimeHintsAgent @EnabledIfRuntimeHintsAgent}. + * + * @author Brian Clozel + */ +public class RuntimeHintsAgentCondition implements ExecutionCondition { + + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + return findAnnotation(context.getElement(), EnabledIfRuntimeHintsAgent.class) + .map(annotation -> checkRuntimeHintsAgentPresence()) + .orElse(ConditionEvaluationResult.enabled("@RuntimeHintsTest is not present")); + } + + static ConditionEvaluationResult checkRuntimeHintsAgentPresence() { + return RuntimeHintsAgent.isLoaded() ? ConditionEvaluationResult.enabled("RuntimeHintsAgent is loaded") + : ConditionEvaluationResult.disabled("RuntimeHintsAgent is not loaded on the current JVM"); + } +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java new file mode 100644 index 00000000000..753d0186dcb --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocations.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.aot.test.agent; + +import java.util.List; +import java.util.stream.Stream; + +import org.assertj.core.api.AssertProvider; + +import org.springframework.aot.agent.RecordedInvocation; + +/** + * A wrapper for {@link RecordedInvocation} that is the starting point for {@code RuntimeHints} AssertJ assertions. + * + * @author Brian Clozel + * @since 6.0 + * @see RuntimeHintsInvocationsAssert + */ +public class RuntimeHintsInvocations implements AssertProvider { + + private final List invocations; + + RuntimeHintsInvocations(List invocations) { + this.invocations = invocations; + } + + @Override + public RuntimeHintsInvocationsAssert assertThat() { + return new RuntimeHintsInvocationsAssert(this); + } + + Stream recordedInvocations() { + return this.invocations.stream(); + } + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java new file mode 100644 index 00000000000..c29e6aa0508 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsInvocationsAssert.java @@ -0,0 +1,119 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.aot.test.agent; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.assertj.core.api.AbstractAssert; +import org.assertj.core.error.BasicErrorMessageFactory; +import org.assertj.core.error.ErrorMessageFactory; + +import org.springframework.aot.agent.RecordedInvocation; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; +import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.util.Assert; + +/** + * AssertJ {@link org.assertj.core.api.Assert assertions} that can be applied to an {@link RuntimeHintsInvocations}. + * + * @author Brian Clozel + * @since 6.0 + */ +public class RuntimeHintsInvocationsAssert extends AbstractAssert { + + + List> configurers = new ArrayList<>(); + + RuntimeHintsInvocationsAssert(RuntimeHintsInvocations invocations) { + super(invocations, RuntimeHintsInvocationsAssert.class); + } + + public RuntimeHintsInvocationsAssert withRegistrar(RuntimeHintsRegistrar registrar) { + this.configurers.add(hints -> registrar.registerHints(hints, getClass().getClassLoader())); + return this; + } + + public RuntimeHintsInvocationsAssert withSpringFactoriesRegistrars(String location) { + List registrars = SpringFactoriesLoader.forResourceLocation(location).load(RuntimeHintsRegistrar.class); + this.configurers.add(hints -> registrars.forEach(registrar -> registrar.registerHints(hints, getClass().getClassLoader()))); + return this; + } + + private void configureRuntimeHints(RuntimeHints hints) { + this.configurers.forEach(configurer -> configurer.accept(hints)); + } + + /** + * Verifies that each recorded invocation match at least once hint in the provided {@link RuntimeHints}. + *

+ * Example:

+	 * RuntimeHints hints = new RuntimeHints();
+	 * hints.reflection().registerType(MyType.class);
+	 * assertThat(invocations).allMatch(hints); 
+ * @param runtimeHints the runtime hints configuration to test against + * @throws AssertionError if any of the recorded invocations has no match in the provided hints + */ + public void match(RuntimeHints runtimeHints) { + Assert.notNull(runtimeHints, "RuntimeHints should not be null"); + configureRuntimeHints(runtimeHints); + List noMatchInvocations = + this.actual.recordedInvocations().filter(invocation -> !invocation.matches(runtimeHints)).toList(); + if (!noMatchInvocations.isEmpty()) { + throwAssertionError(errorMessageForInvocation(noMatchInvocations.get(0))); + } + } + + + private ErrorMessageFactory errorMessageForInvocation(RecordedInvocation invocation) { + return new BasicErrorMessageFactory("%nMissing <%s> for invocation <%s> on type <%s> %nwith arguments %s.%nStacktrace:%n<%s>", + invocation.getHintType().hintClassName(), invocation.getMethodReference(), + invocation.getInstanceTypeReference(), invocation.getArguments(), + formatStackTrace(invocation.getStackFrames())); + } + + private String formatStackTrace(Stream stackTraceElements) { + return stackTraceElements + .map(f -> f.getClassName() + "#" + f.getMethodName() + + ", Line " + f.getLineNumber()).collect(Collectors.joining(System.lineSeparator())); + } + + /** + * Verifies that the count of recorded invocations match the expected one. + *

+ * Example:

+	 * assertThat(invocations).hasCount(42); 
+ * @param count the expected invocations count + * @return {@code this} assertion object. + * @throws AssertionError if the number of recorded invocations doesn't match the expected one + */ + public RuntimeHintsInvocationsAssert hasCount(long count) { + isNotNull(); + long invocationsCount = this.actual.recordedInvocations().count(); + if(invocationsCount != count) { + throwAssertionError(new BasicErrorMessageFactory("%nNumber of recorded invocations does not match, expected <%n> but got <%n>.", + invocationsCount, count)); + } + return this; + } + + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java new file mode 100644 index 00000000000..abad3a85528 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/RuntimeHintsRecorder.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.aot.test.agent; + +import java.util.ArrayDeque; +import java.util.Deque; + +import org.springframework.aot.agent.RecordedInvocation; +import org.springframework.aot.agent.RecordedInvocationsListener; +import org.springframework.aot.agent.RecordedInvocationsPublisher; +import org.springframework.aot.agent.RuntimeHintsAgent; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.util.Assert; + +/** + * Invocations relevant to {@link RuntimeHints} recorded during the execution of a block + * of code instrumented by the {@link RuntimeHintsAgent}. + * + * @author Brian Clozel + * @since 6.0 + */ +public final class RuntimeHintsRecorder { + + private final RuntimeHintsInvocationsListener listener; + + private RuntimeHintsRecorder() { + this.listener = new RuntimeHintsInvocationsListener(); + } + + /** + * Record all method invocations relevant to {@link RuntimeHints} that happened + * during the execution of the given action. + * @param action the block of code we want to record invocations from + * @return the recorded invocations + */ + public synchronized static RuntimeHintsInvocations record(Runnable action) { + Assert.notNull(action, "Runnable action should not be null"); + Assert.isTrue(RuntimeHintsAgent.isLoaded(), "RuntimeHintsAgent should be loaded in the current JVM"); + RuntimeHintsRecorder recorder = new RuntimeHintsRecorder(); + RecordedInvocationsPublisher.addListener(recorder.listener); + try { + action.run(); + } + finally { + RecordedInvocationsPublisher.removeListener(recorder.listener); + } + return new RuntimeHintsInvocations(recorder.listener.recordedInvocations.stream().toList()); + } + + + private static final class RuntimeHintsInvocationsListener implements RecordedInvocationsListener { + + private final Deque recordedInvocations = new ArrayDeque<>(); + + @Override + public void onInvocation(RecordedInvocation invocation) { + this.recordedInvocations.addLast(invocation); + } + + } + +} diff --git a/spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java b/spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java new file mode 100644 index 00000000000..dc7ecdddfd6 --- /dev/null +++ b/spring-core-test/src/main/java/org/springframework/aot/test/agent/package-info.java @@ -0,0 +1,9 @@ +/** + * Testing support for the {@link org.springframework.aot.agent.RuntimeHintsAgent}. + */ +@NonNullApi +@NonNullFields +package org.springframework.aot.test.agent; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/src/checkstyle/checkstyle-suppressions.xml b/src/checkstyle/checkstyle-suppressions.xml index 41d0560396d..24bbed66a8e 100644 --- a/src/checkstyle/checkstyle-suppressions.xml +++ b/src/checkstyle/checkstyle-suppressions.xml @@ -40,6 +40,7 @@ +