diff --git a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc index 137c0496f84..2501e72e4b3 100644 --- a/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc +++ b/framework-docs/modules/ROOT/pages/core/expressions/evaluation.adoc @@ -531,7 +531,6 @@ following kinds of expressions cannot be compiled. * Expressions relying on the conversion service * Expressions using custom resolvers * Expressions using overloaded operators -* Expressions using `Optional` with the null-safe or Elvis operator * Expressions using array construction syntax * Expressions using selection or projection * Expressions using bean references diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java b/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java index f34ff43d1a9..2edd7ad47a6 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/CodeFlow.java @@ -1017,6 +1017,24 @@ public class CodeFlow implements Opcodes { }; } + /** + * If the provided descriptor represents a {@link java.util.Optional}, insert + * the necessary bytecode to unwrap it. + *

An empty {@code Optional} will be replaced with {@code null}. + * @param mv the method visitor into which instructions should be inserted + * @param descriptor the descriptor of a type that may or may not need unwrapping + * @since 7.1 + * @see java.util.Optional#orElse(Object) + */ + public static void insertOptionalUnwrapIfNecessary(MethodVisitor mv, @Nullable String descriptor) { + if ("Ljava/util/Optional".equals(descriptor)) { + // Push 'null' onto the stack as the argument for orElse + mv.visitInsn(ACONST_NULL); + // Invoke java.util.Optional.orElse(null) + mv.visitMethodInsn(INVOKEVIRTUAL, "java/util/Optional", "orElse", + "(Ljava/lang/Object;)Ljava/lang/Object;", false); + } + } /** * Interface used to generate fields. diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java index c3ebc93ac21..155730f9ec5 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Elvis.java @@ -19,6 +19,8 @@ package org.springframework.expression.spel.ast; import java.util.Objects; import java.util.Optional; +import org.jspecify.annotations.Nullable; + import org.springframework.asm.Label; import org.springframework.asm.MethodVisitor; import org.springframework.expression.EvaluationException; @@ -43,6 +45,15 @@ import org.springframework.util.Assert; */ public class Elvis extends SpelNodeImpl { + /** + * Tracks the descriptor for the value contained in an {@link Optional} that + * was unwrapped in {@link #getValueInternal(ExpressionState)} using the + * null-safe operator. + * @since 7.1 + */ + private @Nullable String unwrappedOptionalDescriptor; + + public Elvis(int startPos, int endPos, SpelNodeImpl... args) { super(startPos, endPos, args); } @@ -65,10 +76,10 @@ public class Elvis extends SpelNodeImpl { Object leftHandValue = leftHandTypedValue.getValue(); if (leftHandValue instanceof Optional optional) { - // Compilation is currently not supported for Optional with the Elvis operator. - this.exitTypeDescriptor = null; if (optional.isPresent()) { - result = new TypedValue(optional.get()); + Object value = optional.get(); + this.unwrappedOptionalDescriptor = CodeFlow.toDescriptor(value.getClass()); + result = new TypedValue(value); } else { result = this.children[1].getValueInternal(state); @@ -94,7 +105,8 @@ public class Elvis extends SpelNodeImpl { public boolean isCompilable() { SpelNodeImpl condition = this.children[0]; SpelNodeImpl ifNullValue = this.children[1]; - String conditionDescriptor = condition.exitTypeDescriptor; + String conditionDescriptor = (this.unwrappedOptionalDescriptor != null ? + this.unwrappedOptionalDescriptor : condition.exitTypeDescriptor); String ifNullValueDescriptor = ifNullValue.exitTypeDescriptor; return (condition.isCompilable() && ifNullValue.isCompilable() && @@ -116,7 +128,12 @@ public class Elvis extends SpelNodeImpl { this.children[0].generateCode(mv, cf); String lastDesc = cf.lastDescriptor(); Assert.state(lastDesc != null, "No last descriptor"); - CodeFlow.insertBoxIfNecessary(mv, lastDesc.charAt(0)); + if ("Ljava/util/Optional".equals(lastDesc)) { + CodeFlow.insertOptionalUnwrapIfNecessary(mv, lastDesc); + } + else { + CodeFlow.insertBoxIfNecessary(mv, lastDesc.charAt(0)); + } cf.exitCompilationScope(); mv.visitInsn(DUP); @@ -147,7 +164,8 @@ public class Elvis extends SpelNodeImpl { private void computeExitTypeDescriptor() { SpelNodeImpl condition = this.children[0]; SpelNodeImpl ifNullValue = this.children[1]; - String conditionDescriptor = condition.exitTypeDescriptor; + String conditionDescriptor = (this.unwrappedOptionalDescriptor != null ? + this.unwrappedOptionalDescriptor : condition.exitTypeDescriptor); String ifNullValueDescriptor = ifNullValue.exitTypeDescriptor; if (this.exitTypeDescriptor == null && conditionDescriptor != null && ifNullValueDescriptor != null) { diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java index 09b270d5bfc..2a6ef4b2cce 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Indexer.java @@ -346,6 +346,7 @@ public class Indexer extends SpelNodeImpl { Label skipIfNull = null; if (isNullSafe()) { + CodeFlow.insertOptionalUnwrapIfNecessary(mv, descriptor); mv.visitInsn(DUP); skipIfNull = new Label(); Label continueLabel = new Label(); diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java index 096c55eb0ed..2b37979548c 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/MethodReference.java @@ -77,6 +77,15 @@ public class MethodReference extends SpelNodeImpl { private @Nullable Character originalPrimitiveExitTypeDescriptor; + /** + * Tracks whether an {@link Optional} was unwrapped in + * {@link #getValueInternal(EvaluationContext, Object, TypeDescriptor, Object[])} + * using the null-safe operator and therefore needs to be unwrapped in a + * compiled expression. + * @since 7.1 + */ + private boolean unwrapOptional; + private volatile @Nullable CachedMethodExecutor cachedExecutor; @@ -133,6 +142,7 @@ public class MethodReference extends SpelNodeImpl { List argumentTypes = getArgumentTypes(arguments); Optional fallbackOptionalTarget = null; boolean isEmptyOptional = false; + this.unwrapOptional = false; if (isNullSafe()) { if (target == null) { @@ -142,6 +152,7 @@ public class MethodReference extends SpelNodeImpl { if (optional.isPresent()) { target = optional.get(); fallbackOptionalTarget = optional; + this.unwrapOptional = true; } else { isEmptyOptional = true; @@ -193,6 +204,9 @@ public class MethodReference extends SpelNodeImpl { if (searchResult.methodExecutor != null) { executorToUse = searchResult.methodExecutor; targetToUse = fallbackOptionalTarget; + // If we end up using a method on the original Optional instance, + // we don't need to unwrap the Optional in the compiled expression. + this.unwrapOptional = false; } } // If we got this far, that means we failed to find an executor for both the @@ -384,6 +398,9 @@ public class MethodReference extends SpelNodeImpl { Label skipIfNull = null; if (isNullSafe() && (descriptor != null || !isStatic)) { + if (this.unwrapOptional) { + CodeFlow.insertOptionalUnwrapIfNecessary(mv, descriptor); + } skipIfNull = new Label(); Label continueLabel = new Label(); mv.visitInsn(DUP); diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java index b0cfd040b85..07313f1a9bd 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/PropertyOrFieldReference.java @@ -72,6 +72,14 @@ public class PropertyOrFieldReference extends SpelNodeImpl { private @Nullable String originalPrimitiveExitTypeDescriptor; + /** + * Tracks whether an {@link Optional} was unwrapped in + * {@link #readProperty(TypedValue, EvaluationContext, String)} using the + * null-safe operator and therefore needs to be unwrapped in a compiled expression. + * @since 7.1 + */ + private boolean unwrapOptional; + private volatile @Nullable PropertyAccessor cachedReadAccessor; private volatile @Nullable PropertyAccessor cachedWriteAccessor; @@ -196,6 +204,7 @@ public class PropertyOrFieldReference extends SpelNodeImpl { Object target = originalTarget; Optional fallbackOptionalTarget = null; boolean isEmptyOptional = false; + this.unwrapOptional = false; if (isNullSafe()) { if (target == null) { @@ -205,6 +214,7 @@ public class PropertyOrFieldReference extends SpelNodeImpl { if (optional.isPresent()) { target = optional.get(); fallbackOptionalTarget = optional; + this.unwrapOptional = true; } else { isEmptyOptional = true; @@ -249,6 +259,9 @@ public class PropertyOrFieldReference extends SpelNodeImpl { evalContext, fallbackOptionalTarget, name); } this.cachedReadAccessor = accessor; + // If we end up using a property on the original Optional instance, + // we don't need to unwrap the Optional in the compiled expression. + this.unwrapOptional = false; return accessor.read(evalContext, fallbackOptionalTarget, name); } } @@ -357,6 +370,9 @@ public class PropertyOrFieldReference extends SpelNodeImpl { Label skipIfNull = null; if (isNullSafe()) { + if (this.unwrapOptional) { + CodeFlow.insertOptionalUnwrapIfNecessary(mv, cf.lastDescriptor()); + } mv.visitInsn(DUP); skipIfNull = new Label(); Label continueLabel = new Label(); diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java index 4308c0e5a83..09a3dcdb672 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/OptionalNullSafetyTests.java @@ -354,14 +354,18 @@ class OptionalNullSafetyTests { } - record Jedi(String name) { + public record Jedi(String name) { + + public static Jedi from(String name) { + return new Jedi(name); + } public String salutation(String salutation) { return salutation + " " + this.name; } } - static class Service { + public static class Service { public Optional findJediByName(@Nullable String name) { if (name == null) { diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java index 0fbd98ab010..58ec82749b2 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java @@ -29,6 +29,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.StringTokenizer; import java.util.stream.Collectors; @@ -37,6 +38,7 @@ import java.util.stream.Stream; import example.Color; import example.FruitMap; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -49,6 +51,8 @@ import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.IndexAccessor; import org.springframework.expression.TypedValue; +import org.springframework.expression.spel.OptionalNullSafetyTests.Jedi; +import org.springframework.expression.spel.OptionalNullSafetyTests.Service; import org.springframework.expression.spel.ast.CompoundExpression; import org.springframework.expression.spel.ast.InlineList; import org.springframework.expression.spel.ast.OpLT; @@ -62,6 +66,7 @@ import org.springframework.expression.spel.support.ReflectiveIndexAccessor; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.expression.spel.testdata.PersonInOtherPackage; import org.springframework.expression.spel.testresources.Person; +import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; import static java.util.stream.Collectors.joining; @@ -6294,6 +6299,258 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests { } } + /** + * @since 7.1 + */ + @Nested + class OptionalNullSafeTests { // gh-36330 + + private final StandardEvaluationContext context = new StandardEvaluationContext(); + + + @Test + void accessOptionalPropertyOnEmptyOptionalViaNullSafeOperator() { + String exitDescriptor = CodeFlow.toDescriptor(Boolean.class); + context.setVariable("service", new Service()); + expression = parser.parseExpression("#service.findJediByName('')?.present"); + + assertThat(expression.getValue(context, Boolean.class)).isFalse(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context, Boolean.class)).isFalse(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertIsCompiled(expression); + } + + @Test + void accessOptionalPropertyOnNonEmptyOptionalViaNullSafeOperator() { + String exitDescriptor = CodeFlow.toDescriptor(Boolean.class); + context.setVariable("service", new Service()); + expression = parser.parseExpression("#service.findJediByName('Yoda')?.present"); + + assertThat(expression.getValue(context, Boolean.class)).isTrue(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context, Boolean.class)).isTrue(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertIsCompiled(expression); + } + + @Test + void invokeOptionalMethodOnEmptyOptionalViaNullSafeOperator() { + // Object instead of String, since Optional#orElse returns T. + String exitDescriptor = CodeFlow.toDescriptor(Object.class); + context.setVariable("service", new Service()); + expression = parser.parseExpression("#service.findJediByName('')?.orElse('Luke')"); + + assertThat(expression.getValue(context)).isEqualTo("Luke"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isEqualTo("Luke"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertIsCompiled(expression); + } + + @Test + void invokeOptionalMethodOnNonEmptyOptionalViaNullSafeOperator() { + // Object instead of Jedi, since Optional#orElse returns T. + String exitDescriptor = CodeFlow.toDescriptor(Object.class); + context.setVariable("service", new Service()); + expression = parser.parseExpression("#service.findJediByName('Yoda')?.orElse('Luke')"); + + assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda")); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda")); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertIsCompiled(expression); + } + + @Test + void accessPropertyOnOptionalViaNullSafeOperator() { + String exitDescriptor = CodeFlow.toDescriptor(String.class); + expression = parser.parseExpression("#jedi?.name"); + + // 1) Start with empty Optional + context.setVariable("jedi", Optional.empty()); + assertThat(expression.getValue(context)).isNull(); + // Cannot compile before the "name" property type is known. + assertThat(getAst().getExitDescriptor()).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + + // 2) Switch to non-empty Optional + context.setVariable("jedi", Optional.of(new Jedi("Yoda"))); + assertThat(expression.getValue(context)).isEqualTo("Yoda"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isEqualTo("Yoda"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + + // 3) Switch back to empty Optional + context.setVariable("jedi", Optional.empty()); + assertThat(expression.getValue(context)).isNull(); + // Exit descriptor hasn't changed. + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertIsCompiled(expression); + } + + @Test + void invokeMethodOnOptionalViaNullSafeOperator() { + String exitDescriptor = CodeFlow.toDescriptor(String.class); + expression = parser.parseExpression("#jedi?.salutation('Master')"); + + // 1) Start with empty Optional + context.setVariable("jedi", Optional.empty()); + assertThat(expression.getValue(context)).isNull(); + // Cannot compile before the "salutation()" return type is known. + assertThat(getAst().getExitDescriptor()).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + + // 2) Switch to non-empty Optional + context.setVariable("jedi", Optional.of(new Jedi("Yoda"))); + assertThat(expression.getValue(context)).isEqualTo("Master Yoda"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isEqualTo("Master Yoda"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + + // 3) Switch back to empty Optional + context.setVariable("jedi", Optional.empty()); + assertThat(expression.getValue(context)).isNull(); + // Exit descriptor hasn't changed. + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + } + + @Test + void accessIndexOnOptionalViaNullSafeOperator() { + // Object instead of String, since Indexer.CollectionIndexingValueRef + // sets the exit descriptor to Object for any Collection. + String exitDescriptor = CodeFlow.toDescriptor(Object.class); + expression = parser.parseExpression("#fruits?.[1]"); + + // 1) Start with empty Optional + context.setVariable("fruits", Optional.empty()); + assertThat(expression.getValue(context)).isNull(); + // Cannot compile before the indexed value type is known. + assertThat(getAst().getExitDescriptor()).isNull(); + assertCannotCompile(expression); + assertThat(expression.getValue(context)).isNull(); + + // 2) Switch to non-empty Optional + context.setVariable("fruits", Optional.of(List.of("banana", "lemon", "mango"))); + assertThat(expression.getValue(context)).isEqualTo("lemon"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isEqualTo("lemon"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + + // 3) Switch back to empty Optional + context.setVariable("fruits", Optional.empty()); + assertThat(expression.getValue(context)).isNull(); + // Exit descriptor hasn't changed. + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isNull(); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + } + } + + /** + * @since 7.1 + */ + @Nested + class OptionalElvisTests { // gh-36330 + + private final StandardEvaluationContext context = new StandardEvaluationContext(); + + @BeforeEach + void configureContext() { + context.setVariable("service", new Service()); + context.registerFunction("jedi", ClassUtils.getMethod(Jedi.class, "from", String.class)); + } + + @Test + void elvisOperatorOnEmptyOptional() { + // Object instead of Optional or String, since the Elvis operator uses + // "the easiest to compute common supertype" if the types for the + // LHS and RHS do not match. + String exitDescriptor = CodeFlow.toDescriptor(Object.class); + expression = parser.parseExpression("#service.findJediByName('') ?: 'unknown'"); + + assertThat(expression.getValue(context)).isEqualTo("unknown"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isEqualTo("unknown"); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + } + + @Test + void elvisOperatorOnNonEmptyOptionalWithNonMatchingElseType() { + // Object instead of Jedi or String, since the Elvis operator uses + // "the easiest to compute common supertype" if the types for the + // LHS and RHS do not match. + String exitDescriptor = CodeFlow.toDescriptor(Object.class); + expression = parser.parseExpression("#service.findJediByName('Yoda') ?: 'unknown'"); + + assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda")); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda")); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + } + + @Test + void elvisOperatorOnNonEmptyOptionalWithMatchingElseType() { + // Jedi, since the known types for the LHS and RHS eventually match. + String exitDescriptor = CodeFlow.toDescriptor(Jedi.class); + expression = parser.parseExpression("#service.findJediByName(#name) ?: #jedi('unknown')"); + + // 1) Start with non-empty Optional + context.setVariable("name", "Yoda"); + assertThat(expression.getValue(context)).isEqualTo(new Jedi("Yoda")); + // Cannot compile before the types for the LHS and RHS are known. + assertThat(getAst().getExitDescriptor()).isNull(); + assertCannotCompile(expression); + assertThat(getAst().getExitDescriptor()).isNull(); + + // 2) Switch to empty Optional + context.setVariable("name", ""); + assertThat(expression.getValue(context)).isEqualTo(new Jedi("unknown")); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + + // 3) Switch back to non-empty Optional + context.setVariable("name", "Luke"); + assertThat(expression.getValue(context)).isEqualTo(new Jedi("Luke")); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + assertCanCompile(expression); + assertIsCompiled(expression); + assertThat(getAst().getExitDescriptor()).isEqualTo(exitDescriptor); + } + } + @Nested class MethodVisibilityTests {