diff --git a/settings.gradle b/settings.gradle index 667c67f7..34e0ec40 100644 --- a/settings.gradle +++ b/settings.gradle @@ -49,6 +49,8 @@ include 'spring-shell-samples' include 'spring-shell-standard' include 'spring-shell-standard-commands' include 'spring-shell-table' +include 'spring-shell-test' +include 'spring-shell-test-autoconfigure' file("${rootDir}/spring-shell-starters").eachDirMatch(~/spring-shell-starter.*/) { include "spring-shell-starters:${it.name}" diff --git a/spring-shell-docs/spring-shell-docs.gradle b/spring-shell-docs/spring-shell-docs.gradle index 9ff0e436..74c54b9d 100644 --- a/spring-shell-docs/spring-shell-docs.gradle +++ b/spring-shell-docs/spring-shell-docs.gradle @@ -7,7 +7,9 @@ description = 'Spring Shell Documentation' dependencies { management platform(project(":spring-shell-management")) implementation project(':spring-shell-starters:spring-shell-starter') + implementation project(':spring-shell-starters:spring-shell-starter-test') testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.awaitility:awaitility' } asciidoctorj { diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-testing-basics.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-testing-basics.adoc new file mode 100644 index 00000000..7fb11d02 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-testing-basics.adoc @@ -0,0 +1,25 @@ +[[using-shell-testing-basics]] +==== Basics +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Spring Shell provides a number of utilities and annotations to help when testing your application. +Test support is provided by two modules: `spring-shell-test` contains core items, and +`spring-shell-test-autoconfigure` supports auto-configuration for tests. + +To test _interactive_ commands. + +==== +[source, java, indent=0] +---- +include::{snippets}/TestingSnippets.java[tag=testing-shelltest-interactive] +---- +==== + +To test _non-interactive_ commands. + +==== +[source, java, indent=0] +---- +include::{snippets}/TestingSnippets.java[tag=testing-shelltest-noninteractive] +---- +==== diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-testing.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-testing.adoc new file mode 100644 index 00000000..2527bd27 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-testing.adoc @@ -0,0 +1,18 @@ +[[using-shell-testing]] +== Testing +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Testing cli application is difficult due to various reasons: + +- There are differences between OS's. +- Within OS there may be different shell implementations in use. +- What goes into a shell and comes out from a shell my be totally + different what you see in shell itself due to control characters. +- Shell may feel syncronous but most likely it is not meaning when + someting is written into it, you can't assume next update in + in it is not final. + +NOTE: Testing support is currently under development and will be + unstable for various parts. + +include::using-shell-testing-basics.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/using-shell.adoc b/spring-shell-docs/src/main/asciidoc/using-shell.adoc index 9f1249b6..1278d918 100644 --- a/spring-shell-docs/src/main/asciidoc/using-shell.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-shell.adoc @@ -13,3 +13,5 @@ include::using-shell-components.adoc[] include::using-shell-customization.adoc[] include::using-shell-execution.adoc[] + +include::using-shell-testing.adoc[] diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/TestingSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/TestingSnippets.java new file mode 100644 index 00000000..6cd0542c --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/TestingSnippets.java @@ -0,0 +1,84 @@ +/* + * Copyright 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.shell.docs; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.shell.test.ShellAssertions; +import org.springframework.shell.test.ShellTestClient; +import org.springframework.shell.test.ShellTestClient.InteractiveShellSession; +import org.springframework.shell.test.ShellTestClient.NonInteractiveShellSession; +import org.springframework.shell.test.autoconfigure.ShellTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; + +import static org.awaitility.Awaitility.await; + +class TestingSnippets { + + // tag::testing-shelltest-interactive[] + @ShellTest + @DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) + class InteractiveTestSample { + + @Autowired + ShellTestClient client; + + @Test + void test() { + InteractiveShellSession session = client + .interactive() + .run(); + + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()) + .containsText("shell"); + }); + + session.write(session.writeSequence().text("help").carriageReturn().build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()) + .containsText("AVAILABLE COMMANDS"); + }); + } + } + // end::testing-shelltest-interactive[] + + // tag::testing-shelltest-noninteractive[] + @ShellTest + @DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) + class NonInteractiveTestSample { + + @Autowired + ShellTestClient client; + + @Test + void test() { + NonInteractiveShellSession session = client + .nonInterative("help", "help") + .run(); + + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()) + .containsText("AVAILABLE COMMANDS"); + }); + } + } + // end::testing-shelltest-noninteractive[] +} diff --git a/spring-shell-samples/spring-shell-samples.gradle b/spring-shell-samples/spring-shell-samples.gradle index f59f7ae6..78914f5a 100644 --- a/spring-shell-samples/spring-shell-samples.gradle +++ b/spring-shell-samples/spring-shell-samples.gradle @@ -9,7 +9,9 @@ description = 'Spring Shell Samples' dependencies { management platform(project(":spring-shell-management")) implementation project(':spring-shell-starters:spring-shell-starter-jna') + testImplementation project(':spring-shell-starters:spring-shell-starter-test') testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.awaitility:awaitility' } springBoot { diff --git a/spring-shell-samples/src/test/java/org/springframework/shell/samples/AbstractSampleTests.java b/spring-shell-samples/src/test/java/org/springframework/shell/samples/AbstractSampleTests.java new file mode 100644 index 00000000..2db9f4fe --- /dev/null +++ b/spring-shell-samples/src/test/java/org/springframework/shell/samples/AbstractSampleTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 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.shell.samples; + +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.shell.samples.standard.ResolvedCommands; +import org.springframework.shell.test.ShellAssertions; +import org.springframework.shell.test.ShellTestClient; +import org.springframework.shell.test.ShellTestClient.BaseShellSession; +import org.springframework.shell.test.ShellTestClient.InteractiveShellSession; +import org.springframework.shell.test.ShellTestClient.NonInteractiveShellSession; +import org.springframework.shell.test.autoconfigure.ShellTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; + +import static org.awaitility.Awaitility.await; + +@ShellTest +@Import(ResolvedCommands.ResolvedCommandsConfiguration.class) +@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) +public class AbstractSampleTests { + + @Autowired + protected ShellTestClient client; + + protected void assertScreenContainsText(BaseShellSession session, String text) { + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()).containsText(text); + }); + } + + protected BaseShellSession createSession(String command, boolean interactive) { + if (interactive) { + InteractiveShellSession session = client.interactive().run(); + session.write(session.writeSequence().command(command).build()); + return session; + } + else { + String[] commands = command.split(" "); + NonInteractiveShellSession session = client.nonInterative(commands).run(); + return session; + } + } +} diff --git a/spring-shell-samples/src/test/java/org/springframework/shell/samples/e2e/RequiredValueCommandsTests.java b/spring-shell-samples/src/test/java/org/springframework/shell/samples/e2e/RequiredValueCommandsTests.java new file mode 100644 index 00000000..b02b531b --- /dev/null +++ b/spring-shell-samples/src/test/java/org/springframework/shell/samples/e2e/RequiredValueCommandsTests.java @@ -0,0 +1,37 @@ +/* + * Copyright 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.shell.samples.e2e; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import org.springframework.shell.samples.AbstractSampleTests; +import org.springframework.shell.test.ShellTestClient.BaseShellSession; + +class RequiredValueCommandsTests extends AbstractSampleTests { + + @ParameterizedTest + @CsvSource({ + "e2e anno required-value,false", + "e2e reg required-value,false", + "e2e anno required-value,true", + "e2e reg required-value,true" + }) + void shouldRequireOption(String command, boolean interactive) { + BaseShellSession session = createSession(command, interactive); + assertScreenContainsText(session, "Missing mandatory option"); + } +} diff --git a/spring-shell-samples/src/test/java/org/springframework/shell/samples/standard/ComponentCommandsTests.java b/spring-shell-samples/src/test/java/org/springframework/shell/samples/standard/ComponentCommandsTests.java new file mode 100644 index 00000000..aa5bc76c --- /dev/null +++ b/spring-shell-samples/src/test/java/org/springframework/shell/samples/standard/ComponentCommandsTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 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.shell.samples.standard; + +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import org.springframework.shell.samples.AbstractSampleTests; +import org.springframework.shell.test.ShellAssertions; +import org.springframework.shell.test.ShellTestClient.BaseShellSession; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class ComponentCommandsTests extends AbstractSampleTests { + + @ParameterizedTest + @CsvSource({ + "component single,false", + "component single,true" + }) + void componentSingle(String command, boolean interactive) { + BaseShellSession session = createSession(command, interactive); + + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(session.screen().lines()).anySatisfy(line -> { + assertThat(line).containsPattern("[>❯] key1"); + }); + }); + + session.write(session.writeSequence().keyDown().build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(session.screen().lines()).anySatisfy(line -> { + assertThat(line).containsPattern("[>❯] key2"); + }); + }); + + session.write(session.writeSequence().cr().build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()).containsText("Got value value2"); + }); + } + + @ParameterizedTest + @CsvSource({ + "component multi,false", + "component multi,true" + }) + void componentMulti(String command, boolean interactive) { + BaseShellSession session = createSession(command, interactive); + + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(session.screen().lines()).anySatisfy(line -> { + assertThat(line).containsPattern("[>❯] (☐|\\[ \\]) key1"); + }); + }); + + session.write(session.writeSequence().space().build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(session.screen().lines()).anySatisfy(line -> { + assertThat(line).containsPattern("[>❯] (☒|\\[x\\]) key1"); + }); + }); + + session.write(session.writeSequence().cr().build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()).containsText("Got value value1,value2"); + }); + } +} diff --git a/spring-shell-starters/spring-shell-starter-test/spring-shell-starter-test.gradle b/spring-shell-starters/spring-shell-starter-test/spring-shell-starter-test.gradle new file mode 100644 index 00000000..0859916f --- /dev/null +++ b/spring-shell-starters/spring-shell-starter-test/spring-shell-starter-test.gradle @@ -0,0 +1,12 @@ +plugins { + id 'org.springframework.shell.starter' +} + +description = 'Spring Shell Starter Test' + +dependencies { + management platform(project(":spring-shell-management")) + api(project(':spring-shell-starters:spring-shell-starter')) + api(project(":spring-shell-test")) + api(project(":spring-shell-test-autoconfigure")) +} diff --git a/spring-shell-test-autoconfigure/spring-shell-test-autoconfigure.gradle b/spring-shell-test-autoconfigure/spring-shell-test-autoconfigure.gradle new file mode 100644 index 00000000..8ab2883b --- /dev/null +++ b/spring-shell-test-autoconfigure/spring-shell-test-autoconfigure.gradle @@ -0,0 +1,21 @@ +plugins { + id 'org.springframework.shell.module' +} + +description = 'Spring Shell Test Autoconfigure' + +dependencies { + management platform(project(":spring-shell-management")) + implementation project(':spring-shell-core') + implementation project(':spring-shell-standard') + implementation project(':spring-shell-test') + implementation project(':spring-shell-autoconfigure') + implementation 'org.springframework:spring-test' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.boot:spring-boot-test-autoconfigure' + implementation 'org.springframework.boot:spring-boot-starter-test' + optional 'org.jline:jline' + optional 'org.assertj:assertj-core' + optional 'org.junit.jupiter:junit-jupiter-api' + testImplementation 'org.awaitility:awaitility' +} diff --git a/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/AutoConfigureShell.java b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/AutoConfigureShell.java new file mode 100644 index 00000000..bf86c5aa --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/AutoConfigureShell.java @@ -0,0 +1,41 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; + +/** + * {@link ImportAutoConfiguration Auto-configuration imports} for typical Shell tests. + * Most tests should consider using {@link ShellTest @ShellTest} rather than using this + * annotation directly. + * + * @author Janne Valkealahti + * @see ShellTest + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ImportAutoConfiguration +public @interface AutoConfigureShell { +} diff --git a/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/AutoConfigureShellTestClient.java b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/AutoConfigureShellTestClient.java new file mode 100644 index 00000000..c4a174fb --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/AutoConfigureShellTestClient.java @@ -0,0 +1,39 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; + +/** + * Annotation that can be applied to a test class to enable and configure + * auto-configuration of shell client. + * + * @author Janne Valkealahti + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ImportAutoConfiguration +public @interface AutoConfigureShellTestClient { +} diff --git a/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellAutoConfiguration.java b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellAutoConfiguration.java new file mode 100644 index 00000000..0f24305e --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellAutoConfiguration.java @@ -0,0 +1,147 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.shell.boot.JLineShellAutoConfiguration; +import org.springframework.shell.boot.TerminalCustomizer; +import org.springframework.shell.test.jediterm.terminal.TtyConnector; +import org.springframework.shell.test.jediterm.terminal.ui.JediTermWidget; +import org.springframework.shell.test.jediterm.terminal.ui.TerminalSession; + +@AutoConfiguration(before = JLineShellAutoConfiguration.class) +public class ShellAutoConfiguration { + + @Bean + TerminalCustomizer terminalStreamsTerminalCustomizer(TerminalStreams terminalStreams) { + return builder -> { + builder.streams(terminalStreams.input, terminalStreams.output) + .jansi(false) + .jna(false); + }; + } + + @Bean + TerminalStreams terminalStreams() { + return new TerminalStreams(); + } + + @Bean + TtyConnector ttyConnector(TerminalStreams terminalStreams) { + return new TestTtyConnector(terminalStreams.myReader, terminalStreams.myWriter); + } + + @Bean + TerminalSession terminalSession(TtyConnector ttyConnector) { + JediTermWidget widget = new JediTermWidget(80, 24); + widget.setTtyConnector(ttyConnector); + return widget; + } + + public static class TerminalStreams { + PipedInputStream input; + PipedOutputStream output; + InputStreamReader myReader; + OutputStreamWriter myWriter; + + public TerminalStreams() { + input = new PipedInputStream(); + output = new PipedOutputStream(); + try { + myReader = new InputStreamReader(new PipedInputStream(this.output)); + myWriter = new OutputStreamWriter(new PipedOutputStream(this.input)); + } catch (IOException e) { + } + + } + } + + private static class TestTtyConnector implements TtyConnector { + + private final static Logger log = LoggerFactory.getLogger(TestTtyConnector.class); + InputStreamReader myReader; + OutputStreamWriter myWriter; + + TestTtyConnector(InputStreamReader myReader, OutputStreamWriter myWriter) { + this.myReader = myReader; + this.myWriter = myWriter; + } + + @Override + public boolean init() { + return true; + } + + @Override + public void close() { + } + + @Override + public String getName() { + return null; + } + + @Override + public int read(char[] buf, int offset, int length) throws IOException { + log.trace("read1"); + int read = this.myReader.read(buf, offset, length); + log.trace("read2 {}", read); + return read; + } + + @Override + public void write(byte[] bytes) throws IOException { + log.trace("write1 {}", bytes); + this.myWriter.write(new String(bytes)); + this.myWriter.flush(); + log.trace("write2"); + } + + @Override + public boolean isConnected() { + return true; + } + + @Override + public void write(String string) throws IOException { + this.write(string.getBytes()); + } + + @Override + public int waitFor() throws InterruptedException { + return 0; + } + + @Override + public boolean ready() throws IOException { + log.trace("ready1"); + boolean ready = myReader.ready(); + log.trace("ready2 {}", ready); + return ready; + } + } + +} diff --git a/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTest.java b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTest.java new file mode 100644 index 00000000..15700963 --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration; +import org.springframework.boot.test.autoconfigure.filter.TypeExcludeFilters; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.core.annotation.AliasFor; +import org.springframework.core.env.Environment; +import org.springframework.test.context.BootstrapWith; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Annotation that can be used for a Shell test that focuses + * only on Shell components. + * + * @author Janne Valkealahti + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@BootstrapWith(ShellTestContextBootstrapper.class) +@ExtendWith(SpringExtension.class) +@OverrideAutoConfiguration(enabled = false) +@TypeExcludeFilters(ShellTypeExcludeFilter.class) +@AutoConfigureShell +@AutoConfigureShellTestClient +@ImportAutoConfiguration +public @interface ShellTest { + + /** + * Properties in form {@literal key=value} that should be added to the Spring + * {@link Environment} before the test runs. + * + * @return the properties to add + */ + String[] properties() default {}; + + /** + * Determines if default filtering should be used with + * {@link SpringBootApplication @SpringBootApplication}. + * + * @see #includeFilters() + * @see #excludeFilters() + * @return if default filters should be used + */ + boolean useDefaultFilters() default true; + + /** + * A set of include filters which can be used to add otherwise filtered beans to the + * application context. + * + * @return include filters to apply + */ + Filter[] includeFilters() default {}; + + /** + * A set of exclude filters which can be used to filter beans that would otherwise be + * added to the application context. + * + * @return exclude filters to apply + */ + Filter[] excludeFilters() default {}; + + /** + * Auto-configuration exclusions that should be applied for this test. + * + * @return auto-configuration exclusions to apply + */ + @AliasFor(annotation = ImportAutoConfiguration.class, attribute = "exclude") + Class[] excludeAutoConfiguration() default {}; +} diff --git a/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTestClientAutoConfiguration.java b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTestClientAutoConfiguration.java new file mode 100644 index 00000000..64f9dcb4 --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTestClientAutoConfiguration.java @@ -0,0 +1,42 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import org.jline.reader.LineReader; +import org.jline.terminal.Terminal; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.shell.Shell; +import org.springframework.shell.jline.PromptProvider; +import org.springframework.shell.test.ShellTestClient; +import org.springframework.shell.test.jediterm.terminal.ui.TerminalSession; + +/** + * + * @author Janne Valkealahti + */ +@AutoConfiguration +public class ShellTestClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + ShellTestClient shellTestClient(TerminalSession widget, Shell shell, PromptProvider promptProvider, + LineReader lineReader, Terminal terminal) { + return ShellTestClient.builder(widget, shell, promptProvider, lineReader, terminal).build(); + } +} diff --git a/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTestContextBootstrapper.java b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTestContextBootstrapper.java new file mode 100644 index 00000000..948053a6 --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTestContextBootstrapper.java @@ -0,0 +1,35 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import org.springframework.boot.test.context.SpringBootTestContextBootstrapper; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; +import org.springframework.test.context.TestContextBootstrapper; + +/** + * {@link TestContextBootstrapper} for {@link ShellTest @ShellTest} support. + * + * @author Janne Valkealahti + */ +public class ShellTestContextBootstrapper extends SpringBootTestContextBootstrapper { + + @Override + protected String[] getProperties(Class testClass) { + return MergedAnnotations.from(testClass, SearchStrategy.INHERITED_ANNOTATIONS).get(ShellTest.class) + .getValue("properties", String[].class).orElse(null); + } +} diff --git a/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTypeExcludeFilter.java b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTypeExcludeFilter.java new file mode 100644 index 00000000..5cbbd4fe --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/java/org/springframework/shell/test/autoconfigure/ShellTypeExcludeFilter.java @@ -0,0 +1,49 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.boot.test.autoconfigure.filter.StandardAnnotationCustomizableTypeExcludeFilter; +import org.springframework.shell.standard.ShellComponent; + +/** + * {@link TypeExcludeFilter} for {@link ShellTest @ShellTest}. + * + * @author Janne Valkealahti + */ +public class ShellTypeExcludeFilter extends StandardAnnotationCustomizableTypeExcludeFilter { + + private static final Set> DEFAULT_INCLUDES; + + static { + Set> includes = new LinkedHashSet<>(); + includes.add(ShellComponent.class); + DEFAULT_INCLUDES = Collections.unmodifiableSet(includes); + } + + ShellTypeExcludeFilter(Class testClass) { + super(testClass); + } + + @Override + protected Set> getDefaultIncludes() { + return DEFAULT_INCLUDES; + } +} diff --git a/spring-shell-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.shell.test.autoconfigure.AutoConfigureShell.imports b/spring-shell-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.shell.test.autoconfigure.AutoConfigureShell.imports new file mode 100644 index 00000000..1be56725 --- /dev/null +++ b/spring-shell-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.shell.test.autoconfigure.AutoConfigureShell.imports @@ -0,0 +1,16 @@ +org.springframework.shell.test.autoconfigure.ShellAutoConfiguration +org.springframework.shell.test.autoconfigure.ShellTestClientAutoConfiguration +org.springframework.shell.boot.CommandCatalogAutoConfiguration +org.springframework.shell.boot.CompleterAutoConfiguration +org.springframework.shell.boot.ComponentFlowAutoConfiguration +org.springframework.shell.boot.ExitCodeAutoConfiguration +org.springframework.shell.boot.JLineAutoConfiguration +org.springframework.shell.boot.JLineShellAutoConfiguration +org.springframework.shell.boot.LineReaderAutoConfiguration +org.springframework.shell.boot.ParameterResolverAutoConfiguration +org.springframework.shell.boot.ShellContextAutoConfiguration +org.springframework.shell.boot.SpringShellAutoConfiguration +org.springframework.shell.boot.StandardAPIAutoConfiguration +org.springframework.shell.boot.StandardCommandsAutoConfiguration +org.springframework.shell.boot.ThemingAutoConfiguration +org.springframework.shell.boot.UserConfigAutoConfiguration diff --git a/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/ShellTestIntegrationTests.java b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/ShellTestIntegrationTests.java new file mode 100644 index 00000000..5cf75c8c --- /dev/null +++ b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/ShellTestIntegrationTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.assertj.core.api.Condition; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.shell.test.ShellAssertions; +import org.springframework.shell.test.ShellTestClient; +import org.springframework.shell.test.ShellTestClient.InteractiveShellSession; +import org.springframework.shell.test.ShellTestClient.NonInteractiveShellSession; +import org.springframework.shell.test.autoconfigure.app.ExampleShellApplication; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.annotation.DirtiesContext.ClassMode; +import org.springframework.test.context.ContextConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@ContextConfiguration(classes = ExampleShellApplication.class) +@ShellTest +@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD) +public class ShellTestIntegrationTests { + + @Autowired + ShellTestClient client; + + @Test + void testInteractive1() throws Exception { + InteractiveShellSession session = client.interactive().run(); + + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()).containsText("shell"); + }); + + session.write(session.writeSequence().text("help").carriageReturn().build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()).containsText("AVAILABLE COMMANDS"); + }); + + session.write(session.writeSequence().carriageReturn().build()); + await().atMost(4, TimeUnit.SECONDS).untilAsserted(() -> { + List lines = session.screen().lines(); + Condition prompt = new Condition<>(line -> line.contains("shell:"), "Shell has expected prompt"); + assertThat(lines).areExactly(3, prompt); + }); + + session.write(session.writeSequence().ctrl('l').build()); + await().atMost(4, TimeUnit.SECONDS).untilAsserted(() -> { + List lines = session.screen().lines(); + Condition prompt = new Condition<>(line -> line.contains("shell:"), "Shell has expected prompt"); + assertThat(lines).areExactly(1, prompt); + }); + + session.write(session.writeSequence().ctrl('c').build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(session.isComplete()).isTrue(); + }); + } + + @Test + void testInteractive2() throws Exception { + InteractiveShellSession session = client.interactive().run(); + + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + ShellAssertions.assertThat(session.screen()).containsText("shell:"); + }); + + session.write(session.writeSequence().ctrl('c').build()); + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(session.isComplete()).isTrue(); + }); + } + + @Test + void testNonInteractive() throws Exception { + Condition helpCondition = new Condition<>(line -> line.contains("AVAILABLE COMMANDS"), + "Help has expected output"); + + Condition helpHelpCondition = new Condition<>(line -> line.contains("help - Display help about available commands"), + "Help help has expected output"); + + NonInteractiveShellSession session = client.nonInterative("help").run(); + + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + List lines = session.screen().lines(); + assertThat(lines).areExactly(1, helpCondition); + assertThat(lines).areNot(helpHelpCondition); + }); + + session.write(session.writeSequence().clearScreen().build()); + NonInteractiveShellSession session2 = client.nonInterative("help", "help").run(); + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + List lines = session2.screen().lines(); + assertThat(lines).areNot(helpCondition); + assertThat(lines).areExactly(1, helpHelpCondition); + }); + + session.write(session.writeSequence().ctrl('c').build()); + await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(session.isComplete()).isTrue(); + }); + } + + @Test + void testNonInteractive2() throws Exception { + Condition helloCondition = new Condition<>(line -> line.contains("hello"), + "Hello has expected output"); + + NonInteractiveShellSession session = client.nonInterative("hello").run(); + + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + List lines = session.screen().lines(); + assertThat(lines).areExactly(1, helloCondition); + }); + } +} diff --git a/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/ShellTestPropertiesIntegrationTests.java b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/ShellTestPropertiesIntegrationTests.java new file mode 100644 index 00000000..031b5dd1 --- /dev/null +++ b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/ShellTestPropertiesIntegrationTests.java @@ -0,0 +1,44 @@ +/* + * Copyright 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.shell.test.autoconfigure; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.shell.test.autoconfigure.app.ExampleShellApplication; +import org.springframework.test.context.ContextConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for the {@link ShellTest#properties properties} attribute of + * {@link ShellTest @ShellTest}. + * + * @author Janne Valkealahti + */ +@ShellTest(properties = "spring.profiles.active=test") +@ContextConfiguration(classes = ExampleShellApplication.class) +public class ShellTestPropertiesIntegrationTests { + + @Autowired + private Environment environment; + + @Test + void environmentWithNewProfile() { + assertThat(this.environment.getActiveProfiles()).containsExactly("test"); + } +} diff --git a/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/app/ExampleShellApplication.java b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/app/ExampleShellApplication.java new file mode 100644 index 00000000..e512c92c --- /dev/null +++ b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/app/ExampleShellApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 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.shell.test.autoconfigure.app; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.shell.test.autoconfigure.ShellTest; + +/** + * Example {@link SpringBootApplication @SpringBootApplication} for use with + * {@link ShellTest @ShellTest} tests. + * + * @author Janne Valkealahti + */ +@SpringBootApplication +public class ExampleShellApplication { +} diff --git a/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/app/HelloCommand.java b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/app/HelloCommand.java new file mode 100644 index 00000000..c6821277 --- /dev/null +++ b/spring-shell-test-autoconfigure/src/test/java/org/springframework/shell/test/autoconfigure/app/HelloCommand.java @@ -0,0 +1,28 @@ +/* + * Copyright 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.shell.test.autoconfigure.app; + +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; + +@ShellComponent +public class HelloCommand { + + @ShellMethod + public String hello() { + return "hello"; + } +} diff --git a/spring-shell-test-autoconfigure/src/test/resources/application.yml b/spring-shell-test-autoconfigure/src/test/resources/application.yml new file mode 100644 index 00000000..33417f5b --- /dev/null +++ b/spring-shell-test-autoconfigure/src/test/resources/application.yml @@ -0,0 +1,9 @@ +#logging: +# file: +# name: xxx.log +# level: +# root: debug +# org: +# jline: debug +# springframework: +# shell: trace diff --git a/spring-shell-test/spring-shell-test.gradle b/spring-shell-test/spring-shell-test.gradle new file mode 100644 index 00000000..bde8d398 --- /dev/null +++ b/spring-shell-test/spring-shell-test.gradle @@ -0,0 +1,14 @@ +plugins { + id 'org.springframework.shell.module' +} + +description = 'Spring Shell Test' + +dependencies { + management platform(project(":spring-shell-management")) + implementation project(':spring-shell-core') + optional 'org.assertj:assertj-core' + optional 'org.junit.jupiter:junit-jupiter-api' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.awaitility:awaitility' +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/ShellAssertions.java b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellAssertions.java new file mode 100644 index 00000000..fe4c0532 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellAssertions.java @@ -0,0 +1,42 @@ +/* + * Copyright 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.shell.test; + +import org.assertj.core.api.InstanceOfAssertFactory; + +/** + * Entry point for assertion methods for shell components. + * + * @author Janne Valkealahti + */ +public class ShellAssertions { + + /** + * Instance of a assert factory for {@link ShellScreen}. + */ + public static final InstanceOfAssertFactory SHELLSCREEN = new InstanceOfAssertFactory<>(ShellScreen.class, + ShellAssertions::assertThat); + + /** + * Creates an instance of {@link ShellScreenAssert}. + * + * @param actual the actual value + * @return the created assertion object + */ + public static ShellScreenAssert assertThat(ShellScreen actual) { + return new ShellScreenAssert(actual); + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/ShellScreen.java b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellScreen.java new file mode 100644 index 00000000..baddd686 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellScreen.java @@ -0,0 +1,58 @@ +/* + * Copyright 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.shell.test; + +import java.util.List; + +/** + * Interface representing a shell screen. + * + * @author Janne Valkealahti + */ +public interface ShellScreen { + + /** + * Gets a visible lines in a screen. + * + * @return visible lines in a screen + */ + List lines(); + + /** + * Get {@code ShellScreen} out of lines. + * + * @param lines the lines + * @return instance of shell screen + */ + static ShellScreen of(List lines) { + return new DefaultShellScreen(lines); + } + + class DefaultShellScreen implements ShellScreen { + + List lines; + + DefaultShellScreen(List lines) { + this.lines = lines; + } + + @Override + public List lines() { + return lines; + } + } + +} \ No newline at end of file diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/ShellScreenAssert.java b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellScreenAssert.java new file mode 100644 index 00000000..8be3e9b6 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellScreenAssert.java @@ -0,0 +1,50 @@ +/* + * Copyright 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.shell.test; + +import java.util.List; +import java.util.stream.Collectors; + +import org.assertj.core.api.AbstractAssert; + +/** + * Asserts for {@link ShellScreen}. + * + * @author Janne Valkealahti + */ +public class ShellScreenAssert extends AbstractAssert { + + public ShellScreenAssert(ShellScreen actual) { + super(actual, ShellScreenAssert.class); + } + + /** + * Verifies that text if found from a screen. + * + * @param text the text to look for + * @return this assertion object + */ + public ShellScreenAssert containsText(String text) { + isNotNull(); + List lines = actual.lines(); + boolean match = lines.stream().filter(n -> n.contains(text)).findFirst().isPresent(); + if (!match) { + failWithMessage("Expected to find %s from screen but was %s", text, + lines.stream().collect(Collectors.joining("\n"))); + } + return this; + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/ShellTestClient.java b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellTestClient.java new file mode 100644 index 00000000..3ea66bce --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellTestClient.java @@ -0,0 +1,357 @@ +/* + * Copyright 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.shell.test; + +import java.io.Closeable; +import java.io.IOException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.atomic.AtomicInteger; + +import org.jline.reader.LineReader; +import org.jline.terminal.Terminal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.DefaultApplicationArguments; +import org.springframework.shell.Shell; +import org.springframework.shell.ShellRunner; +import org.springframework.shell.context.DefaultShellContext; +import org.springframework.shell.jline.InteractiveShellRunner; +import org.springframework.shell.jline.NonInteractiveShellRunner; +import org.springframework.shell.jline.PromptProvider; +import org.springframework.shell.test.jediterm.terminal.ui.TerminalSession; + +/** + * Client for terminal session which can be used as a programmatic way + * to interact with a shell application. In a typical test it is required + * to write into a shell and read what is visible in a shell. + * + * @author Janne Valkealahti + */ +public interface ShellTestClient extends Closeable { + + /** + * Run interactive shell session. + * + * @return session for chaining + */ + InteractiveShellSession interactive(); + + /** + * Run non-interactive command session. + * + * @param args the command arguments + * @return session for chaining + */ + NonInteractiveShellSession nonInterative(String... args); + + /** + * Read the screen. + * + * @return the screen + */ + ShellScreen screen(); + + /** + * Get an instance of a builder. + * + * @param terminalSession the terminal session + * @param shell the shell + * @param promptProvider the prompt provider + * @param lineReader the line reader + * @param terminal the terminal + * @return a Builder + */ + public static Builder builder(TerminalSession terminalSession, Shell shell, PromptProvider promptProvider, + LineReader lineReader, Terminal terminal) { + return new DefaultBuilder(terminalSession, shell, promptProvider, lineReader, terminal); + } + + /** + * Builder interface for {@code ShellClient}. + */ + interface Builder { + + /** + * Build a shell client. + * + * @return a shell client + */ + ShellTestClient build(); + } + + interface BaseShellSession> { + + /** + * Get a write sequencer. + * + * @return a write sequencer + */ + ShellWriteSequence writeSequence(); + + /** + * Read the screen. + * + * @return the screen + */ + ShellScreen screen(); + + /** + * Write plain text into a shell. + * + * @param text the text + * @return client for chaining + */ + T write(String text); + + /** + * Run a session. + * + * @return client for chaining + */ + T run(); + + boolean isComplete(); + } + + interface InteractiveShellSession extends BaseShellSession { + } + + interface NonInteractiveShellSession extends BaseShellSession { + } + + static class DefaultBuilder implements Builder { + + private TerminalSession terminalSession; + private Shell shell; + private PromptProvider promptProvider; + private LineReader lineReader; + private Terminal terminal; + + DefaultBuilder(TerminalSession terminalSession, Shell shell, PromptProvider promptProvider, + LineReader lineReader, Terminal terminal) { + this.terminalSession = terminalSession; + this.shell = shell; + this.promptProvider = promptProvider; + this.lineReader = lineReader; + this.terminal = terminal; + } + + @Override + public ShellTestClient build() { + return new DefaultShellClient(terminalSession, shell, promptProvider, lineReader, terminal); + } + } + + static class DefaultShellClient implements ShellTestClient { + + private final static Logger log = LoggerFactory.getLogger(DefaultShellClient.class); + private TerminalSession terminalSession; + private Shell shell; + private PromptProvider promptProvider; + private LineReader lineReader; + private Thread runnerThread; + private Terminal terminal; + private final BlockingQueue blockingQueue = new LinkedBlockingDeque<>(10); + + DefaultShellClient(TerminalSession terminalSession, Shell shell, PromptProvider promptProvider, + LineReader lineReader, Terminal terminal) { + this.terminalSession = terminalSession; + this.shell = shell; + this.promptProvider = promptProvider; + this.lineReader = lineReader; + this.terminal = terminal; + } + + @Override + public InteractiveShellSession interactive() { + terminalSession.start(); + if (runnerThread == null) { + runnerThread = new Thread(new ShellRunnerTask(this.blockingQueue)); + runnerThread.start(); + } + return new DefaultInteractiveShellSession(shell, promptProvider, lineReader, blockingQueue, terminalSession, terminal); + } + + @Override + public NonInteractiveShellSession nonInterative(String... args) { + terminalSession.start(); + if (runnerThread == null) { + runnerThread = new Thread(new ShellRunnerTask(this.blockingQueue)); + runnerThread.start(); + } + return new DefaultNonInteractiveShellSession(shell, args, blockingQueue, terminalSession, terminal); + } + + @Override + public ShellScreen screen() { + return ShellScreen.of(terminalSession.getTerminalTextBuffer().getScreen()); + } + + @Override + public void close() throws IOException { + log.debug("Closing ShellClient"); + if (runnerThread != null) { + runnerThread.interrupt(); + } + runnerThread = null; + terminalSession.close(); + } + } + + static class DefaultInteractiveShellSession implements InteractiveShellSession { + + private Shell shell; + private PromptProvider promptProvider; + private LineReader lineReader; + private BlockingQueue blockingQueue; + private TerminalSession terminalSession; + private Terminal terminal; + private final AtomicInteger state = new AtomicInteger(-2); + + public DefaultInteractiveShellSession(Shell shell, PromptProvider promptProvider, LineReader lineReader, + BlockingQueue blockingQueue, TerminalSession terminalSession, Terminal terminal) { + this.shell = shell; + this.promptProvider = promptProvider; + this.lineReader = lineReader; + this.blockingQueue = blockingQueue; + this.terminalSession = terminalSession; + this.terminal = terminal; + } + + @Override + public ShellWriteSequence writeSequence() { + return ShellWriteSequence.of(terminal); + } + + @Override + public InteractiveShellSession write(String text) { + terminalSession.getTerminalStarter().sendString(text); + return this; + } + + @Override + public ShellScreen screen() { + return ShellScreen.of(terminalSession.getTerminalTextBuffer().getScreen()); + } + + @Override + public InteractiveShellSession run() { + ShellRunner runner = new InteractiveShellRunner(lineReader, promptProvider, shell, new DefaultShellContext()); + ApplicationArguments appArgs = new DefaultApplicationArguments(); + this.blockingQueue.add(new ShellRunnerTaskData(runner, appArgs, state)); + return this; + } + + @Override + public boolean isComplete() { + return state.get() >= 0; + } + } + + static class DefaultNonInteractiveShellSession implements NonInteractiveShellSession { + + private Shell shell; + private String[] args; + private BlockingQueue blockingQueue; + private TerminalSession terminalSession; + private Terminal terminal; + private final AtomicInteger state = new AtomicInteger(-2); + + public DefaultNonInteractiveShellSession(Shell shell, String[] args, + BlockingQueue blockingQueue, TerminalSession terminalSession, Terminal terminal) { + this.shell = shell; + this.args = args; + this.blockingQueue = blockingQueue; + this.terminalSession = terminalSession; + this.terminal = terminal; + } + + @Override + public ShellWriteSequence writeSequence() { + return ShellWriteSequence.of(terminal); + } + + @Override + public NonInteractiveShellSession write(String text) { + terminalSession.getTerminalStarter().sendString(text); + return this; + } + + @Override + public ShellScreen screen() { + return ShellScreen.of(terminalSession.getTerminalTextBuffer().getScreen()); + } + + @Override + public NonInteractiveShellSession run() { + ShellRunner runner = new NonInteractiveShellRunner(shell, new DefaultShellContext()); + ApplicationArguments appArgs = new DefaultApplicationArguments(args); + this.blockingQueue.add(new ShellRunnerTaskData(runner, appArgs, state)); + return this; + } + + @Override + public boolean isComplete() { + return state.get() >= 0; + } + } + + static record ShellRunnerTaskData( + ShellRunner runner, + ApplicationArguments args, + AtomicInteger state + ) {} + + static class ShellRunnerTask implements Runnable { + + private final static Logger log = LoggerFactory.getLogger(ShellRunnerTask.class); + private BlockingQueue blockingQueue; + + ShellRunnerTask(BlockingQueue blockingQueue) { + this.blockingQueue = blockingQueue; + } + + @Override + public void run() { + log.trace("ShellRunnerTask start"); + try { + Thread.currentThread().setName("ShellRunnerTask"); + while (true) { + ShellRunnerTaskData data = blockingQueue.take(); + if (data.runner == null) { + return; + } + try { + log.trace("Running {}", data.runner()); + data.state().set(-1); + data.runner().run(data.args()); + data.state().set(0); + log.trace("Running done {}", data.runner()); + } catch (Exception e) { + data.state().set(1); + log.trace("ShellRunnerThread ex", e); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + log.trace("ShellRunnerTask end"); + } + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/ShellWriteSequence.java b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellWriteSequence.java new file mode 100644 index 00000000..9f85624c --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/ShellWriteSequence.java @@ -0,0 +1,206 @@ +/* + * Copyright 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.shell.test; + +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.InfoCmp; + +/** + * Interface sequencing various things into terminal aware text types. + * + * @author Janne Valkealahti + */ +public interface ShellWriteSequence { + + /** + * Sequence terminal clear screen. + * + * @return a sequence for chaining + */ + ShellWriteSequence clearScreen(); + + /** + * Sequence terminal carriage return. + * + * @return a sequence for chaining + */ + ShellWriteSequence carriageReturn(); + + /** + * Sequence from command with expected {@code carriage return}. + * + * @param command the command + * @return a sequence for chaining + */ + ShellWriteSequence command(String command); + + /** + * Sequence terminal carriage return. Alias for {@link #carriageReturn} + * + * @return a sequence for chaining + * @see #carriageReturn() + */ + ShellWriteSequence cr(); + + /** + * Sequence text. + * + * @param text the text + * @return a sequence for chaining + */ + ShellWriteSequence text(String text); + + /** + * Sequence terminal key down. + * + * @return a sequence for chaining + */ + ShellWriteSequence keyDown(); + + /** + * Sequence terminal key left. + * + * @return a sequence for chaining + */ + ShellWriteSequence keyLeft(); + + /** + * Sequence terminal key right. + * + * @return a sequence for chaining + */ + ShellWriteSequence keyRight(); + + /** + * Sequence terminal key up. + * + * @return a sequence for chaining + */ + ShellWriteSequence keyUp(); + + /** + * Sequence terminal space. + * + * @return a sequence for chaining + */ + ShellWriteSequence space(); + + /** + * Sequence terminal ctrl. + * + * @return a sequence for chaining + */ + ShellWriteSequence ctrl(char c); + + /** + * Build the result. + * + * @return the result + */ + String build(); + + /** + * Get a new instance of a {@code ShellWriteSequence}. + * + * @param terminal the terminal + * @return instance of a write sequence + */ + static ShellWriteSequence of(Terminal terminal) { + return new DefaultShellWriteSequence(terminal); + } + + static class DefaultShellWriteSequence implements ShellWriteSequence { + + private final Terminal terminal; + private StringBuilder buf = new StringBuilder(); + + DefaultShellWriteSequence(Terminal terminal) { + this.terminal = terminal; + } + + @Override + public ShellWriteSequence carriageReturn() { + this.buf.append(KeyMap.key(this.terminal, InfoCmp.Capability.carriage_return)); + return this; + } + + @Override + public ShellWriteSequence clearScreen() { + String ansiClearScreen = KeyMap.key(this.terminal, InfoCmp.Capability.clear_screen); + this.buf.append(ansiClearScreen); + return this; + } + + @Override + public ShellWriteSequence ctrl(char c) { + String ctrl = KeyMap.ctrl(c); + this.buf.append(ctrl); + return this; + } + + @Override + public ShellWriteSequence command(String command) { + this.text(command); + return carriageReturn(); + } + + @Override + public ShellWriteSequence cr() { + return carriageReturn(); + } + + @Override + public ShellWriteSequence keyUp() { + this.buf.append(KeyMap.key(this.terminal, InfoCmp.Capability.key_up)); + return this; + } + + @Override + public ShellWriteSequence keyDown() { + this.buf.append(KeyMap.key(this.terminal, InfoCmp.Capability.key_down)); + return this; + } + + @Override + public ShellWriteSequence keyLeft() { + this.buf.append(KeyMap.key(this.terminal, InfoCmp.Capability.key_left)); + return this; + } + + @Override + public ShellWriteSequence keyRight() { + this.buf.append(KeyMap.key(this.terminal, InfoCmp.Capability.key_right)); + return this; + } + + @Override + public ShellWriteSequence text(String text) { + this.buf.append(text); + return this; + } + + @Override + public ShellWriteSequence space() { + return this.text(" "); + } + + @Override + public String build() { + return buf.toString(); + } + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/ArrayTerminalDataStream.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/ArrayTerminalDataStream.java new file mode 100644 index 00000000..68ba6100 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/ArrayTerminalDataStream.java @@ -0,0 +1,96 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import java.io.IOException; + +import org.springframework.shell.test.jediterm.terminal.util.CharUtils; + +/** + * Takes data from underlying char array. + * + * @author jediterm authors + */ +public class ArrayTerminalDataStream implements TerminalDataStream { + + protected char[] buf; + protected int offset; + protected int length; + + public ArrayTerminalDataStream(char[] buf, int offset, int length) { + this.buf = buf; + this.offset = offset; + this.length = length; + } + + public ArrayTerminalDataStream(char[] buf) { + this(buf, 0, buf.length); + } + + @Override + public char getChar() throws IOException { + if (this.length == 0) { + throw new EOF(); + } + + this.length--; + + return this.buf[this.offset++]; + } + + @Override + public void pushChar(final char c) throws EOF { + if (this.offset == 0) { + // Pushed back too many... shift it up to the end. + + char[] newBuf; + if (this.buf.length - this.length == 0) { + newBuf = new char[this.buf.length + 1]; + } + else { + newBuf = this.buf; + } + this.offset = newBuf.length - this.length; + System.arraycopy(this.buf, 0, newBuf, this.offset, this.length); + this.buf = newBuf; + } + + this.length++; + this.buf[--this.offset] = c; + } + + @Override + public String readNonControlCharacters(int maxChars) throws IOException { + String nonControlCharacters = CharUtils.getNonControlCharacters(maxChars, this.buf, this.offset, this.length); + + this.offset += nonControlCharacters.length(); + this.length -= nonControlCharacters.length(); + + return nonControlCharacters; + } + + @Override + public void pushBackBuffer(final char[] bytes, final int length) throws EOF { + for (int i = length - 1; i >= 0; i--) { + pushChar(bytes[i]); + } + } + + @Override + public boolean isEmpty() { + return this.length == 0; + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/CursorShape.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/CursorShape.java new file mode 100644 index 00000000..66dacac4 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/CursorShape.java @@ -0,0 +1,30 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +/** + * Current cursor shape as described by https://vt100.net/docs/vt510-rm/DECSCUSR.html. + * + * @author jediterm authors + */ +public enum CursorShape { + BLINK_BLOCK, + STEADY_BLOCK, + BLINK_UNDERLINE, + STEADY_UNDERLINE, + BLINK_VERTICAL_BAR, + STEADY_VERTICAL_BAR +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/DataStreamIteratingEmulator.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/DataStreamIteratingEmulator.java new file mode 100644 index 00000000..3f57d6ca --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/DataStreamIteratingEmulator.java @@ -0,0 +1,59 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import java.io.IOException; + +import org.springframework.shell.test.jediterm.terminal.emulator.Emulator; + +/** + * @author jediterm authors + */ +public abstract class DataStreamIteratingEmulator implements Emulator { + + protected final TerminalDataStream myDataStream; + protected final Terminal myTerminal; + + private boolean myEof = false; + + public DataStreamIteratingEmulator(TerminalDataStream dataStream, Terminal terminal) { + myDataStream = dataStream; + myTerminal = terminal; + } + + @Override + public boolean hasNext() { + return !myEof; + } + + @Override + public void resetEof() { + myEof = false; + } + + @Override + public void next() throws IOException { + try { + char b = myDataStream.getChar(); + processChar(b, myTerminal); + } + catch (TerminalDataStream.EOF e) { + myEof = true; + } + } + + protected abstract void processChar(char ch, Terminal terminal) throws IOException; +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/RequestOrigin.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/RequestOrigin.java new file mode 100644 index 00000000..b5f0f613 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/RequestOrigin.java @@ -0,0 +1,29 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + + +/** + * + * @author jediterm authors + */ +public enum RequestOrigin{ + User, + Remote +} \ No newline at end of file diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/StyledTextConsumer.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/StyledTextConsumer.java new file mode 100644 index 00000000..493bf682 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/StyledTextConsumer.java @@ -0,0 +1,41 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import org.springframework.shell.test.jediterm.terminal.model.CharBuffer; + +/** + * General interface that obtains styled range of characters at coordinates (x, y) when the screen starts at startRow + * + * @author jediterm authors + */ +public interface StyledTextConsumer { + /** + * + * @param x indicates starting column of the characters + * @param y indicates row of the characters + * @param style style of characters + * @param characters text characters + * @param startRow number of the first row. + * It can be different for different buffers, e.g. backBuffer starts from 0, textBuffer and scrollBuffer from -count + */ + void consume(int x, int y, TextStyle style, CharBuffer characters, int startRow); + + void consumeNul(int x, int y, int nulIndex, TextStyle style, CharBuffer characters, int startRow); + + void consumeQueue(int x, int y, int nulIndex, int startRow); + +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/StyledTextConsumerAdapter.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/StyledTextConsumerAdapter.java new file mode 100644 index 00000000..d8fda825 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/StyledTextConsumerAdapter.java @@ -0,0 +1,40 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import org.springframework.shell.test.jediterm.terminal.model.CharBuffer; + +/** + * + * @author jediterm authors + */ +public class StyledTextConsumerAdapter implements StyledTextConsumer { + + public void consume(int x, int y, TextStyle style, CharBuffer characters, int startRow) { + // to override + } + + @Override + public void consumeNul(int x, int y, int nulIndex, TextStyle style, CharBuffer characters, int startRow) { + // to override + } + + @Override + public void consumeQueue(int x, int y, int nulIndex, int startRow) { + // to override + } + +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/Terminal.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/Terminal.java new file mode 100644 index 00000000..0257c260 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/Terminal.java @@ -0,0 +1,170 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import java.io.UnsupportedEncodingException; +import java.util.concurrent.CompletableFuture; + +import org.springframework.shell.test.jediterm.terminal.model.StyleState; + +/** + * Executes terminal commands interpreted by {@link org.springframework.shell.test.jediterm.terminal.emulator.Emulator}, receives text + * + * @author jediterm authors + */ +public interface Terminal { + void resize(int width, int height, RequestOrigin origin); + + void resize(int width, int height, RequestOrigin origin, CompletableFuture promptUpdated); + + void beep(); + + void backspace(); + + void horizontalTab(); + + void carriageReturn(); + + void newLine(); + + void mapCharsetToGL(int num); + + void mapCharsetToGR(int num); + + void designateCharacterSet(int tableNumber, char ch); + + void setAnsiConformanceLevel(int level); + + void writeDoubleByte(char[] bytes) throws UnsupportedEncodingException; + + void writeCharacters(String string); + + int distanceToLineEnd(); + + void reverseIndex(); + + void index(); + + void nextLine(); + + void fillScreen(char c); + + void saveCursor(); + + void restoreCursor(); + + void reset(); + + void characterAttributes(TextStyle textStyle); + + void setScrollingRegion(int top, int bottom); + + void scrollUp(int count); + + void scrollDown(int count); + + void resetScrollRegions(); + + void cursorHorizontalAbsolute(int x); + + void linePositionAbsolute(int y); + + void cursorPosition(int x, int y); + + void cursorUp(int countY); + + void cursorDown(int dY); + + void cursorForward(int dX); + + void cursorBackward(int dX); + + void cursorShape(CursorShape shape); + + void eraseInLine(int arg); + + void deleteCharacters(int count); + + int getTerminalWidth(); + + int getTerminalHeight(); + + void eraseInDisplay(int arg); + + void setModeEnabled(TerminalMode mode, boolean enabled); + + void disconnected(); + + int getCursorX(); + + int getCursorY(); + + void singleShiftSelect(int num); + + void setWindowTitle(String name); + + void saveWindowTitleOnStack(); + + void restoreWindowTitleFromStack(); + + void clearScreen(); + + void setCursorVisible(boolean visible); + + void useAlternateBuffer(boolean enabled); + + // byte[] getCodeForKey(int key, int modifiers); + + void setApplicationArrowKeys(boolean enabled); + + void setApplicationKeypad(boolean enabled); + + void setAutoNewLine(boolean enabled); + + StyleState getStyleState(); + + void insertLines(int count); + + void deleteLines(int count); + + void setBlinkingCursor(boolean enabled); + + void eraseCharacters(int count); + + void insertBlankCharacters(int count); + + void clearTabStopAtCursor(); + + void clearAllTabStops(); + + void setTabStopAtCursor(); + + void writeUnwrappedString(String string); + + void setTerminalOutput(TerminalOutputStream terminalOutput); + + void setAltSendsEscape(boolean enabled); + + void deviceStatusReport(String str); + + void deviceAttributes(byte[] response); + + void setBracketedPasteMode(boolean enabled); + + // TerminalColor getWindowForeground(); + + // TerminalColor getWindowBackground(); +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalCopyPasteHandler.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalCopyPasteHandler.java new file mode 100644 index 00000000..ed859342 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalCopyPasteHandler.java @@ -0,0 +1,26 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +/** + * + * @author jediterm authors + */ +public interface TerminalCopyPasteHandler { + void setContents(String text, boolean useSystemSelectionClipboardIfAvailable); + + String getContents(boolean useSystemSelectionClipboardIfAvailable); +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalDataStream.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalDataStream.java new file mode 100644 index 00000000..ad1e27d4 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalDataStream.java @@ -0,0 +1,45 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import java.io.IOException; + +/** + * Represents data communication interface for terminal. + * It allows to {@link #getChar()} by one and {@link #pushChar(char)} back as well as requesting a chunk of plain ASCII + * characters ({@link #readNonControlCharacters(int)} - for faster processing from buffer in the size {@literal <=maxChars}). + * + * + * @author jediterm authors + */ +public interface TerminalDataStream { + + char getChar() throws IOException; + + void pushChar(char c) throws IOException; + + String readNonControlCharacters(int maxChars) throws IOException; + + void pushBackBuffer(char[] bytes, int length) throws IOException; + + boolean isEmpty(); + + class EOF extends IOException { + public EOF() { + super("EOF: There is no more data or connection is lost"); + } + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalDisplay.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalDisplay.java new file mode 100644 index 00000000..ccf8cb55 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalDisplay.java @@ -0,0 +1,47 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +/** + * + * @author jediterm authors + */ +public interface TerminalDisplay { + + int getRowCount(); + + int getColumnCount(); + + void beep(); + + void scrollArea(final int scrollRegionTop, final int scrollRegionSize, int dy); + + String getWindowTitle(); + + void setWindowTitle(String name); + + boolean ambiguousCharsAreDoubleWidth(); + + default void setBracketedPasteMode(boolean enabled) {} + + // default TerminalColor getWindowForeground() { + // return null; + // } + + // default TerminalColor getWindowBackground() { + // return null; + // } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalMode.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalMode.java new file mode 100644 index 00000000..8987ac12 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalMode.java @@ -0,0 +1,131 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * + * @author jediterm authors + */ +public enum TerminalMode { + Null, + CursorKey { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.setApplicationArrowKeys(enabled); + } + }, + ANSI, + WideColumn { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + // Skip resizing as it would require to resize parent container. + // Other terminal emulators (iTerm2, Terminal.app, GNOME Terminal) ignore it too. + terminal.clearScreen(); + terminal.resetScrollRegions(); + } + }, + CursorVisible { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.setCursorVisible(enabled); + } + }, + AlternateBuffer { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.useAlternateBuffer(enabled); + } + }, + SmoothScroll, + ReverseVideo, + OriginMode { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + } + }, + AutoWrap { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + //we do nothing just switching the mode + } + }, + AutoRepeatKeys, + Interlace, + Keypad { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.setApplicationKeypad(enabled); + } + }, + StoreCursor { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + if (enabled) { + terminal.saveCursor(); + } + else { + terminal.restoreCursor(); + } + } + }, + CursorBlinking { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.setBlinkingCursor(enabled); + } + }, + AllowWideColumn, + ReverseWrapAround, + AutoNewLine { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.setAutoNewLine(enabled); + } + }, + KeyboardAction, + InsertMode, + SendReceive, + EightBitInput, //Interpret "meta" key, sets eighth bit. (enables the eightBitInput resource). + // http://www.leonerd.org.uk/hacks/hints/xterm-8bit.html + + AltSendsEscape //See section Alt and Meta Keys in http://invisible-island.net/xterm/ctlseqs/ctlseqs.html + { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.setAltSendsEscape(enabled); + } + }, + + // https://cirw.in/blog/bracketed-paste + // http://www.xfree86.org/current/ctlseqs.html#Bracketed%20Paste%20Mode + BracketedPasteMode { + @Override + public void setEnabled(Terminal terminal, boolean enabled) { + terminal.setBracketedPasteMode(enabled); + } + } + ; + + private static final Logger LOG = LoggerFactory.getLogger(TerminalMode.class); + + public void setEnabled(Terminal terminal, boolean enabled) { + LOG.warn("Mode " + name() + " is not implemented, setting to " + enabled); + } +} \ No newline at end of file diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalOutputStream.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalOutputStream.java new file mode 100644 index 00000000..78606e41 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalOutputStream.java @@ -0,0 +1,44 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +/** + * Sends a response from the terminal emulator. + * + * @author jediterm authors + */ +public interface TerminalOutputStream { + /** + * @deprecated use {@link #sendBytes(byte[], boolean)} instead + */ + @Deprecated + @SuppressWarnings("DeprecatedIsStillUsed") + void sendBytes(byte[] response); + + /** + * @deprecated use {@link #sendString(String, boolean)} instead + */ + @Deprecated + @SuppressWarnings("DeprecatedIsStillUsed") + void sendString(final String string); + + default void sendBytes(byte[] response, boolean userInput) { + sendBytes(response); + } + default void sendString(String string, boolean userInput) { + sendString(string); + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalStarter.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalStarter.java new file mode 100644 index 00000000..a44e3f80 --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TerminalStarter.java @@ -0,0 +1,155 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.shell.test.jediterm.terminal.emulator.Emulator; +import org.springframework.shell.test.jediterm.terminal.emulator.JediEmulator; + + +/** + * Runs terminal emulator. Manages threads to send response. + * + * @author jediterm authors + */ +public class TerminalStarter implements TerminalOutputStream { + + private static final Logger LOG = LoggerFactory.getLogger(TerminalStarter.class); + + private final Emulator myEmulator; + + private final Terminal myTerminal; + + private final TtyConnector myTtyConnector; + + private final ScheduledExecutorService myEmulatorExecutor = Executors.newSingleThreadScheduledExecutor(); + + public TerminalStarter(final Terminal terminal, final TtyConnector ttyConnector, TerminalDataStream dataStream) { + myTtyConnector = ttyConnector; + myTerminal = terminal; + myTerminal.setTerminalOutput(this); + myEmulator = createEmulator(dataStream, terminal); + } + + protected JediEmulator createEmulator(TerminalDataStream dataStream, Terminal terminal) { + return new JediEmulator(dataStream, terminal); + } + + private void execute(Runnable runnable) { + if (!myEmulatorExecutor.isShutdown()) { + myEmulatorExecutor.execute(runnable); + } + } + + public void start() { + try { + while (!Thread.currentThread().isInterrupted() && myEmulator.hasNext()) { + myEmulator.next(); + } + } + catch (final InterruptedIOException e) { + LOG.info("Terminal exiting"); + } + catch (final Exception e) { + if (!myTtyConnector.isConnected()) { + myTerminal.disconnected(); + return; + } + LOG.error("Caught exception in terminal thread", e); + } + } + + // public byte[] getCode(final int key, final int modifiers) { + // return myTerminal.getCodeForKey(key, modifiers); + // } + + public void postResize(int width, int height, RequestOrigin origin) { + execute(() -> { + resize(myEmulator, myTerminal, myTtyConnector, width, height, origin, (millisDelay, runnable) -> { + myEmulatorExecutor.schedule(runnable, millisDelay, TimeUnit.MILLISECONDS); + }); + }); + } + + /** + * Resizes terminal and tty connector, should be called on a pooled thread. + */ + public static void resize(Emulator emulator, Terminal terminal, TtyConnector ttyConnector, int width, int height, + RequestOrigin origin, BiConsumer taskScheduler) { + CompletableFuture promptUpdated = ((JediEmulator)emulator).getPromptUpdatedAfterResizeFuture(taskScheduler); + terminal.resize(width, height, origin, promptUpdated); + ttyConnector.resize(width, height); + } + + @Override + public void sendBytes(final byte[] bytes) { + sendBytes(bytes, false); + } + + @Override + public void sendBytes(final byte[] bytes, boolean userInput) { + execute(() -> { + try { + myTtyConnector.write(bytes); + } + catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + @Override + public void sendString(final String string) { + sendString(string, false); + } + + @Override + public void sendString(final String string, boolean userInput) { + execute(() -> { + try { + + myTtyConnector.write(string); + } + catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + + public void close() { + execute(() -> { + try { + myTtyConnector.close(); + } + catch (Exception e) { + LOG.error("Error closing terminal", e); + } + finally { + myEmulatorExecutor.shutdown(); + } + }); + } +} diff --git a/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TextStyle.java b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TextStyle.java new file mode 100644 index 00000000..f114316b --- /dev/null +++ b/spring-shell-test/src/main/java/org/springframework/shell/test/jediterm/terminal/TextStyle.java @@ -0,0 +1,175 @@ +/* + * Copyright 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.shell.test.jediterm.terminal; + +import java.lang.ref.WeakReference; +import java.util.EnumSet; +import java.util.Objects; +import java.util.WeakHashMap; + +/** + * + * @author jediterm authors + */ +public class TextStyle { + private static final EnumSet