diff --git a/build-logic/src/main/kotlin/cloud-spring.base-conventions.gradle.kts b/build-logic/src/main/kotlin/cloud-spring.base-conventions.gradle.kts index 2c5dd68..69da667 100644 --- a/build-logic/src/main/kotlin/cloud-spring.base-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/cloud-spring.base-conventions.gradle.kts @@ -55,6 +55,5 @@ dependencies { fun DependencyHandlerScope.testDependencies() { testImplementation(libs.truth) - testImplementation(libs.jupiterEngine) - testImplementation(libs.jupiterApi) + testImplementation(libs.awaitility) } diff --git a/cloud-spring/build.gradle.kts b/cloud-spring/build.gradle.kts index d54aff8..a0d9c8a 100644 --- a/cloud-spring/build.gradle.kts +++ b/cloud-spring/build.gradle.kts @@ -20,4 +20,6 @@ dependencies { compileOnlyApi(libs.cloud.annotations) testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.spring.shell.test) + testImplementation(libs.spring.shell.test.autoconfiguration) } diff --git a/cloud-spring/src/main/java/org/incendo/cloud/spring/event/CommandExecutionEvent.java b/cloud-spring/src/main/java/org/incendo/cloud/spring/event/CommandExecutionEvent.java index 8942015..3dc9e7a 100644 --- a/cloud-spring/src/main/java/org/incendo/cloud/spring/event/CommandExecutionEvent.java +++ b/cloud-spring/src/main/java/org/incendo/cloud/spring/event/CommandExecutionEvent.java @@ -30,6 +30,12 @@ import org.springframework.context.ApplicationEvent; import org.springframework.shell.command.CommandContext; +/** + * Event emitted when a command is executed. + * + * @param the command sender type + * @since 1.0.0 + */ @API(status = API.Status.STABLE, since = "1.0.0") public final class CommandExecutionEvent extends ApplicationEvent { diff --git a/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/AnnotationRegistrar.java b/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/AnnotationRegistrar.java index f496bb6..2582cbb 100644 --- a/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/AnnotationRegistrar.java +++ b/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/AnnotationRegistrar.java @@ -30,9 +30,11 @@ import org.incendo.cloud.spring.SpringCommandManager; import org.incendo.cloud.spring.annotation.ScanCommands; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; +@ConditionalOnClass(AnnotationParser.class) @ConditionalOnBean(AnnotationParser.class) @Component @API(status = API.Status.INTERNAL, consumers = "org.incendo.cloud.spring.*", since = "1.0.0") diff --git a/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/CommandRegistrationCoordinator.java b/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/CommandRegistrationCoordinator.java index 9e401ad..f6b6382 100644 --- a/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/CommandRegistrationCoordinator.java +++ b/cloud-spring/src/main/java/org/incendo/cloud/spring/registrar/CommandRegistrationCoordinator.java @@ -26,6 +26,7 @@ import jakarta.annotation.PostConstruct; import java.util.Collection; import java.util.List; +import org.apiguardian.api.API; import org.checkerframework.checker.nullness.qual.NonNull; import org.incendo.cloud.spring.SpringCommandManager; import org.slf4j.Logger; @@ -34,6 +35,7 @@ @SuppressWarnings({"unchecked", "rawtypes"}) @Service +@API(status = API.Status.INTERNAL, consumers = "org.incendo.cloud.spring.*", since = "1.0.0") public class CommandRegistrationCoordinator { private static final Logger LOGGER = LoggerFactory.getLogger(CommandRegistrationCoordinator.class); diff --git a/cloud-spring/src/test/java/org/incendo/cloud/spring/ApplicationIntegrationTest.java b/cloud-spring/src/test/java/org/incendo/cloud/spring/ApplicationIntegrationTest.java new file mode 100644 index 0000000..ed2cf38 --- /dev/null +++ b/cloud-spring/src/test/java/org/incendo/cloud/spring/ApplicationIntegrationTest.java @@ -0,0 +1,119 @@ +// +// MIT License +// +// Copyright (c) 2023 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.spring; + +import cloud.commandframework.Command; +import cloud.commandframework.CommandBean; +import cloud.commandframework.CommandProperties; +import cloud.commandframework.execution.AsynchronousCommandExecutionCoordinator; +import cloud.commandframework.internal.CommandNode; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; + +import static com.google.common.truth.Truth.assertThat; + +/** + * Test that spins up a custom spring application to make sure that things wire up like they're supposed to. + */ +@SpringBootTest +class ApplicationIntegrationTest { + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private SpringCommandManager springCommandManager; + + @Test + @DisplayName("Verify that the application started") + void test() { + assertThat(this.applicationContext).isNotNull(); + assertThat(this.springCommandManager).isNotNull(); + } + + @Test + @DisplayName("Verify that the command execution coordinator was overridden") + void testCommandExecutionCoordinator() { + assertThat(this.springCommandManager.commandExecutionCoordinator()) + .isInstanceOf(AsynchronousCommandExecutionCoordinator.class); + } + + @Test + @DisplayName("Verify that the command bean was registered") + void testCommandRegistration() { + // Act + final CommandNode commandNode = this.springCommandManager.commandTree().getNamedNode("test"); + + // Assert + assertThat(commandNode).isNotNull(); + } + + + @SpringBootApplication + static class TestApplication { + } + + @TestConfiguration + static class TestConfig { + + @Bean + @NonNull CommandSenderSupplier commandSenderSupplier() { + return TestCommandSender::new; + } + + @Bean + @NonNull SpringCommandExecutionCoordinatorResolver commandExecutionCoordinatorResolver() { + return AsynchronousCommandExecutionCoordinator.builder().withAsynchronousParsing().build()::apply; + } + + @Bean + @NonNull TestCommandBean commandBean() { + return new TestCommandBean(); + } + } + + static class TestCommandSender { + + } + + static class TestCommandBean extends CommandBean { + + @Override + protected @NonNull CommandProperties properties() { + return CommandProperties.of("test"); + } + + @Override + protected Command.Builder configure(final Command.Builder builder) { + return builder; + } + } +} diff --git a/cloud-spring/src/test/java/org/incendo/cloud/spring/ShellIntegrationTest.java b/cloud-spring/src/test/java/org/incendo/cloud/spring/ShellIntegrationTest.java new file mode 100644 index 0000000..f439786 --- /dev/null +++ b/cloud-spring/src/test/java/org/incendo/cloud/spring/ShellIntegrationTest.java @@ -0,0 +1,138 @@ +// +// MIT License +// +// Copyright (c) 2023 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.spring; + +import cloud.commandframework.Command; +import cloud.commandframework.CommandBean; +import cloud.commandframework.CommandDescription; +import cloud.commandframework.CommandProperties; +import cloud.commandframework.arguments.flags.CommandFlag; +import cloud.commandframework.context.CommandContext; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.spring.config.CloudSpringConfig; +import org.incendo.cloud.spring.registrar.BeanRegistrar; +import org.incendo.cloud.spring.registrar.CommandRegistrationCoordinator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.shell.test.ShellAssertions; +import org.springframework.shell.test.ShellTestClient; +import org.springframework.shell.test.autoconfigure.ShellTest; +import org.springframework.test.context.ContextConfiguration; + +import static cloud.commandframework.arguments.standard.IntegerParser.integerParser; +import static cloud.commandframework.arguments.standard.StringParser.stringParser; +import static com.google.common.truth.Truth.assertThat; +import static org.awaitility.Awaitility.await; + +@Import(CloudSpringConfig.class) +@ContextConfiguration(classes = { + SpringCommandRegistrationHandler.class, + SpringCommandManager.class, + ShellIntegrationTest.TestConfig.class +}) +@ShellTest +public class ShellIntegrationTest { + + @Autowired + private ShellTestClient client; + + @Test + void testCommandParsing() { + // Arrange + final ShellTestClient.InteractiveShellSession session = this.client.interactive().run(); + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()).containsText("shell"); + }); + + // Act + session.write(session.writeSequence().text("help").carriageReturn().build()); + session.write(session.writeSequence().text("command abc --flag 123").carriageReturn().build()); + + // Assert + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(TestCommand.EXECUTED.get()).isTrue(); + }); + + final CommandContext context = TestCommand.CONTEXT.get(); + assertThat(context.get("string")).isEqualTo("abc"); + assertThat(context.flags().get("flag")).isEqualTo(123); + } + + + @SpringBootApplication + static class TestApplication { + + } + + @TestConfiguration + static class TestConfig { + + @Bean + @NonNull CommandRegistrationCoordinator commandRegistrationCoordinator( + final @NonNull SpringCommandManager springCommandManager + ) { + return new CommandRegistrationCoordinator( + springCommandManager, + List.of(new BeanRegistrar<>(List.of(this.testCommand()))) + ); + } + + @Bean + @NonNull TestCommand testCommand() { + return new TestCommand(); + } + } + + static class TestCommand extends CommandBean { + + private static final AtomicBoolean EXECUTED = new AtomicBoolean(false); + private static final AtomicReference> CONTEXT = new AtomicReference<>(null); + + @Override + protected @NonNull CommandProperties properties() { + return CommandProperties.of("command"); + } + + @Override + protected Command.Builder configure(final Command.Builder builder) { + return builder.commandDescription(CommandDescription.commandDescription("Description!")) + .required("string", stringParser()) + .flag(CommandFlag.builder("flag").withComponent(integerParser())); + } + + @Override + public void execute(final @NonNull CommandContext commandContext) { + EXECUTED.set(true); + CONTEXT.set(commandContext); + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5e2537..209faf0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,8 +18,8 @@ cloud = "2.0.0-SNAPSHOT" springShellBom = "3.1.6" # Test -jupiter = "5.10.1" truth = "1.1.4" +awaitility = "4.2.0" [libraries] spotless = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" } @@ -29,20 +29,21 @@ gradleKotlinJvm = { group = "org.jetbrains.kotlin.jvm", name = "org.jetbrains.ko checkerQual = { group = "org.checkerframework", name = "checker-qual", version.ref = "checkerQual" } apiguardian = { group = "org.apiguardian", name = "apiguardian-api", version.ref = "apiguardian" } slf4j = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } +awaitility = { group = "org.awaitility", name = "awaitility", version.ref = "awaitility" } # Spring spring-shell = { group = "org.springframework.shell", name = "spring-shell-starter" } spring-shell-dependencies = { group = "org.springframework.shell", name = "spring-shell-dependencies", version.ref = "springShellBom" } spring-boot-autoconfigure = { group = "org.springframework.boot", name = "spring-boot-autoconfigure" } spring-boot-starter-test = { group = "org.springframework.boot", name = "spring-boot-starter-test" } +spring-shell-test = { group = "org.springframework.shell", name = "spring-shell-test" } +spring-shell-test-autoconfiguration = { group = "org.springframework.shell", name = "spring-shell-test-autoconfigure" } # Cloud cloud-core = { group = "cloud.commandframework", name = "cloud-core", version.ref = "cloud" } cloud-annotations = { group = "cloud.commandframework", name = "cloud-annotations", version.ref = "cloud" } # Test -jupiterApi = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "jupiter" } -jupiterEngine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "jupiter" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } [bundles]