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