diff --git a/platform/execution-impl/BUILD.bazel b/platform/execution-impl/BUILD.bazel
index ee44cc55de7e..711bbf88f08e 100644
--- a/platform/execution-impl/BUILD.bazel
+++ b/platform/execution-impl/BUILD.bazel
@@ -99,6 +99,8 @@ jvm_library(
"//platform/core-ui",
"//libraries/jediterm-core",
"//libraries/jediterm-ui",
+ "//platform/eel",
+ "//platform/eel-provider",
]
)
### auto-generated section `build intellij.platform.execution.tests` end
diff --git a/platform/execution-impl/intellij.platform.execution.tests.iml b/platform/execution-impl/intellij.platform.execution.tests.iml
index e1d58c7634b6..66400b82d65f 100644
--- a/platform/execution-impl/intellij.platform.execution.tests.iml
+++ b/platform/execution-impl/intellij.platform.execution.tests.iml
@@ -19,5 +19,7 @@
+
+
\ No newline at end of file
diff --git a/platform/execution-impl/src/com/intellij/terminal/TerminalExecutionConsole.java b/platform/execution-impl/src/com/intellij/terminal/TerminalExecutionConsole.java
index 8a3e6a0084bb..64293c67435b 100644
--- a/platform/execution-impl/src/com/intellij/terminal/TerminalExecutionConsole.java
+++ b/platform/execution-impl/src/com/intellij/terminal/TerminalExecutionConsole.java
@@ -40,6 +40,7 @@ import com.jediterm.terminal.model.TerminalTextBuffer;
import com.jediterm.terminal.ui.settings.SettingsProvider;
import com.jediterm.terminal.util.CharUtils;
import com.pty4j.PtyProcess;
+import com.pty4j.windows.conpty.WinConPtyProcess;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -54,9 +55,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
public class TerminalExecutionConsole implements ConsoleView, ObservableConsoleView {
private static final Logger LOG = Logger.getInstance(TerminalExecutionConsole.class);
- private static final String MAKE_CURSOR_INVISIBLE = "\u001b[?25l";
- private static final String MAKE_CURSOR_VISIBLE = "\u001b[?25h";
- private static final String CLEAR_SCREEN = "\u001b[2J";
private final JBTerminalWidget myTerminalWidget;
private final Project myProject;
@@ -120,28 +118,51 @@ public class TerminalExecutionConsole implements ConsoleView, ObservableConsoleV
myDataStream.append(encodeColor(foregroundColor));
}
- if (contentType != ConsoleViewContentType.SYSTEM_OUTPUT && myFirstOutput.compareAndSet(false, true) && startsWithClearScreen(text)) {
- LOG.trace("Clear Screen request detected at the beginning of the output, scheduling a scroll command.");
- // Windows ConPTY generates the 'clear screen' escape sequence (ESC[2J) optionally preceded by a "make cursor invisible" (ESC?25l) before the process output.
- // It pushes the already printed command line into the scrollback buffer which is not displayed by default.
- // In such cases, let's scroll up to display the printed command line.
- BoundedRangeModel verticalScrollModel = myTerminalWidget.getTerminalPanel().getVerticalScrollModel();
- verticalScrollModel.addChangeListener(new javax.swing.event.ChangeListener() {
- @Override
- public void stateChanged(ChangeEvent e) {
- verticalScrollModel.removeChangeListener(this);
- UiNotifyConnector.doWhenFirstShown(myTerminalWidget.getTerminalPanel(), () -> {
- myTerminalWidget.getTerminalPanel().scrollToShowAllOutput();
- });
- }
- });
- }
myDataStream.append(text);
if (foregroundColor != null) {
myDataStream.append((char)Ascii.ESC + "[39m"); //restore default foreground color
}
myContentHelper.onContentTypePrinted(text, ObjectUtils.notNull(contentType, ConsoleViewContentType.NORMAL_OUTPUT));
+
+ if (myFirstOutput.compareAndSet(false, true) &&
+ contentType == ConsoleViewContentType.SYSTEM_OUTPUT &&
+ getPtyProcess() instanceof WinConPtyProcess) {
+ moveScreenToScrollbackBufferAndShowAllOutput();
+ }
+ }
+
+ /**
+ * This method should be called after printing system output (command line) and before
+ * processing output from the ConPTY process.
+ * ConPTY assumes that the screen buffer is empty and the cursor is at (1,1) position when it starts.
+ * However, when a system output is printed, the cursor is moved from the (1,1) position.
+ * As ConPTY knows nothing about the printed system output and the changed cursor position,
+ * it may redraw the screen on top of the printed system output leading to corrupted output (RIDER-131843, WEB-75542).
+ * More details
+ *
+ * To prevent the corrupted output, let's move system output from the screen buffer to the scrollback buffer
+ * and move the cursor back to (1,1) position to make ConPTY happy.
+ *
+ * However, the command line moved to the scrollback buffer is not visible by default.
+ * To ensure that the command output is fully visible, we scroll up programmatically.
+ */
+ private void moveScreenToScrollbackBufferAndShowAllOutput() throws IOException {
+ LOG.trace("Printing command line detected at the beginning of the output, scheduling a scroll command.");
+ BoundedRangeModel verticalScrollModel = myTerminalWidget.getTerminalPanel().getVerticalScrollModel();
+ verticalScrollModel.addChangeListener(new javax.swing.event.ChangeListener() {
+ @Override
+ public void stateChanged(ChangeEvent e) {
+ verticalScrollModel.removeChangeListener(this);
+ UiNotifyConnector.doWhenFirstShown(myTerminalWidget.getTerminalPanel(), () -> {
+ myTerminalWidget.getTerminalPanel().scrollToShowAllOutput();
+ });
+ }
+ });
+ // `ESC[2J` moves screen lines to the scrollback buffer
+ myDataStream.append("\u001b[2J");
+ // `ESC[1;1H` positions the cursor in the top-level corner of the screen buffer
+ myDataStream.append("\u001b[1;1H");
}
@Override
@@ -160,16 +181,6 @@ public class TerminalExecutionConsole implements ConsoleView, ObservableConsoleV
color.getBlue() + "m";
}
- private static boolean startsWithClearScreen(@NotNull String text) {
- // ConPTY will randomly send these commands at any time, so we should skip them:
- int offset = 0;
- while (text.startsWith(MAKE_CURSOR_INVISIBLE, offset) || text.startsWith(MAKE_CURSOR_VISIBLE, offset)) {
- offset += MAKE_CURSOR_INVISIBLE.length();
- }
-
- return text.startsWith(CLEAR_SCREEN, offset);
- }
-
public @NotNull TerminalExecutionConsole withEnterKeyDefaultCodeEnabled(boolean enterKeyDefaultCodeEnabled) {
myEnterKeyDefaultCodeEnabled = enterKeyDefaultCodeEnabled;
return this;
diff --git a/platform/execution-impl/testSources/com/intellij/terminal/TerminalExecutionConsoleTest.kt b/platform/execution-impl/testSources/com/intellij/terminal/TerminalExecutionConsoleTest.kt
index b7f8460b75ab..1c5e4bb3c51b 100644
--- a/platform/execution-impl/testSources/com/intellij/terminal/TerminalExecutionConsoleTest.kt
+++ b/platform/execution-impl/testSources/com/intellij/terminal/TerminalExecutionConsoleTest.kt
@@ -2,19 +2,22 @@
package com.intellij.terminal
import com.intellij.diagnostic.ThreadDumper
-import com.intellij.execution.process.*
+import com.intellij.execution.process.ColoredProcessHandler
+import com.intellij.execution.process.NopProcessHandler
+import com.intellij.execution.process.OSProcessHandler
+import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.openapi.application.UI
import com.intellij.openapi.util.Disposer
+import com.intellij.terminal.testApp.SimpleCliApp
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.jediterm.terminal.TerminalColor
import com.jediterm.terminal.TextStyle
-import com.jediterm.terminal.model.TerminalTextBuffer
-import kotlinx.coroutines.*
-import java.io.InputStream
-import java.io.OutputStream
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
import java.lang.management.ThreadInfo
-import java.util.concurrent.CompletableFuture
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -23,27 +26,25 @@ class TerminalExecutionConsoleTest : BasePlatformTestCase() {
override fun runInDispatchThread(): Boolean = false
- fun `test disposing console should stop emulator thread`(): Unit = timeoutRunBlocking(20.seconds) {
+ fun `test disposing console should stop emulator thread`(): Unit = timeoutRunBlocking(DEFAULT_TEST_TIMEOUT) {
val processHandler = NopProcessHandler()
val console = withContext(Dispatchers.UI) {
TerminalExecutionConsole(project, null)
}
console.attachToProcess(processHandler)
processHandler.startNotify()
- awaitCondition(5.seconds) { findEmulatorThreadInfo() != null }
+ awaitCondition { findEmulatorThreadInfo() != null }
assertNotNull(findEmulatorThreadInfo())
withContext(Dispatchers.UI) {
Disposer.dispose(console)
}
- awaitCondition(5.seconds) { findEmulatorThreadInfo() == null }
+ awaitCondition { findEmulatorThreadInfo() == null }
assertNull(findEmulatorThreadInfo())
}
- private suspend fun awaitCondition(timeout: Duration, condition: () -> Boolean) {
- withTimeoutOrNull(timeout) {
- while (!condition()) {
- delay(100.milliseconds)
- }
+ private suspend fun awaitCondition(condition: () -> Boolean) {
+ while (!condition()) {
+ delay(100.milliseconds)
}
}
@@ -53,44 +54,94 @@ class TerminalExecutionConsoleTest : BasePlatformTestCase() {
}
fun `test support ColoredProcessHandler`(): Unit = timeoutRunBlockingWithConsole { console ->
- val processHandler = ColoredProcessHandler(MockPtyBasedProcess, "my command line", Charsets.UTF_8)
+ val processHandler = ColoredProcessHandler(MockPtyBasedProcess(), "my command line", Charsets.UTF_8)
assertTrue(TerminalExecutionConsole.isAcceptable(processHandler))
console.attachToProcess(processHandler)
processHandler.startNotify()
processHandler.notifyTextAvailable("\u001b[0m", ProcessOutputTypes.STDOUT)
processHandler.notifyTextAvailable("\u001b[32mFoo\u001b[0m", ProcessOutputTypes.STDOUT)
processHandler.setShouldDestroyProcessRecursively(false)
- ProcessTerminatedListener.attach(processHandler, project, $$"Process finished with exit code $EXIT_CODE$")
+ TestProcessTerminationMessage.attach(processHandler)
processHandler.destroyProcess()
- val terminalWidget = console.terminalWidget
- awaitCondition(5.seconds) {
- ScreenText.collect(terminalWidget.terminalTextBuffer).contains(MockPtyBasedProcess.EXIT_CODE.toString())
- }
- assertTrue(terminalWidget.text.startsWith("my command line\nFoo"))
- val screenText = ScreenText.collect(terminalWidget.terminalTextBuffer)
- assertTrue(screenText.contains(Chunk("Foo", TextStyle(TerminalColor(2), null))))
+ console.awaitOutputContainsSubstring(substringToFind = TestProcessTerminationMessage.getMessage(MockPtyBasedProcess.EXIT_CODE))
+ val output = TerminalOutput.collect(console.terminalWidget)
+ output.assertLinesAre(listOf(
+ "my command line",
+ "Foo",
+ TestProcessTerminationMessage.getMessage(MockPtyBasedProcess.EXIT_CODE)
+ ))
+ output.assertContainsChunk(TerminalOutputChunk("Foo", TextStyle(TerminalColor(2), null)))
}
fun `test support OSProcessHandler`(): Unit = timeoutRunBlockingWithConsole { console ->
- val processHandler = OSProcessHandler(MockPtyBasedProcess, "command line", Charsets.UTF_8)
+ val processHandler = OSProcessHandler(MockPtyBasedProcess(), "command line", Charsets.UTF_8)
assertTrue(TerminalExecutionConsole.isAcceptable(processHandler))
console.attachToProcess(processHandler)
processHandler.startNotify()
processHandler.notifyTextAvailable("\u001b[0m", ProcessOutputTypes.STDOUT)
processHandler.notifyTextAvailable("\u001b[32mFoo\u001b[0m", ProcessOutputTypes.STDOUT)
processHandler.notifyTextAvailable("\u001b[43mBar\u001b[0m", ProcessOutputTypes.STDOUT)
- val terminalWidget = console.terminalWidget
- awaitCondition(5.seconds) {
- ScreenText.collect(terminalWidget.terminalTextBuffer).contains("Bar")
- }
- assertTrue(terminalWidget.text.startsWith("command line\nFooBar"))
- val screenText = ScreenText.collect(terminalWidget.terminalTextBuffer)
- assertTrue(screenText.contains(Chunk("Foo", TextStyle(TerminalColor(2), null))))
- assertTrue(screenText.contains(Chunk("Bar", TextStyle(null, TerminalColor(3)))))
+ console.awaitOutputContainsSubstring(substringToFind = "Bar")
+ console.assertOutputStartsWithLines(expectedStartLines = listOf("command line", "FooBar"))
+ val output = TerminalOutput.collect(console.terminalWidget)
+ output.assertContainsChunk(TerminalOutputChunk("Foo", TextStyle(TerminalColor(2), null)))
+ output.assertContainsChunk(TerminalOutputChunk("Bar", TextStyle(null, TerminalColor(3))))
}
- fun timeoutRunBlockingWithConsole(
- timeout: Duration = 20.seconds,
+ fun `test basic SimpleCliApp java process`(): Unit = timeoutRunBlockingWithConsole { console ->
+ val textToPrint = "Hello, World"
+ val javaCommand = SimpleCliApp.NonRuntime.createCommand(SimpleCliApp.Options(
+ textToPrint, 0, null
+ ))
+ val processHandler = createTerminalProcessHandler(javaCommand)
+ console.attachToProcess(processHandler)
+ TestProcessTerminationMessage.attach(processHandler)
+ processHandler.startNotify()
+ console.awaitOutputEndsWithLines(expectedEndLines = listOf(
+ textToPrint,
+ TestProcessTerminationMessage.getMessage(0)
+ ))
+ console.assertOutputStartsWithLines(expectedStartLines = listOf(javaCommand.commandLine))
+ }
+
+ fun `test basic SimpleCliApp java process with non-zero exit code`(): Unit = timeoutRunBlockingWithConsole { console ->
+ val textToPrint = "Something went wrong"
+ val javaCommand = SimpleCliApp.NonRuntime.createCommand(SimpleCliApp.Options(
+ textToPrint, 42, null
+ ))
+ val processHandler = createTerminalProcessHandler(javaCommand)
+ console.attachToProcess(processHandler)
+ TestProcessTerminationMessage.attach(processHandler)
+ processHandler.startNotify()
+ console.awaitOutputEndsWithLines(expectedEndLines = listOf(
+ textToPrint,
+ TestProcessTerminationMessage.getMessage(42)
+ ))
+ console.assertOutputStartsWithLines(expectedStartLines = listOf(javaCommand.commandLine))
+ }
+
+ fun `test read input in SimpleCliApp java process`(): Unit = timeoutRunBlockingWithConsole { console ->
+ val textToPrint = "Enter your name:"
+ val javaCommand = SimpleCliApp.NonRuntime.createCommand(SimpleCliApp.Options(
+ textToPrint, 0, "exit"
+ ))
+ val processHandler = createTerminalProcessHandler(javaCommand)
+ console.attachToProcess(processHandler)
+ TestProcessTerminationMessage.attach(processHandler)
+ processHandler.startNotify()
+ console.awaitOutputEndsWithLines(expectedEndLines = listOf(textToPrint))
+ processHandler.writeToStdinAndHitEnter("exit")
+ console.awaitOutputEndsWithLines(expectedEndLines = listOf(
+ textToPrint + "exit",
+ "Read line: exit",
+ "",
+ TestProcessTerminationMessage.getMessage(0)
+ ))
+ console.assertOutputStartsWithLines(expectedStartLines = listOf(javaCommand.commandLine))
+ }
+
+ private fun timeoutRunBlockingWithConsole(
+ timeout: Duration = DEFAULT_TEST_TIMEOUT,
action: suspend CoroutineScope.(TerminalExecutionConsole) -> T,
): T = timeoutRunBlocking(timeout) {
val console = withContext(Dispatchers.UI) {
@@ -108,50 +159,4 @@ class TerminalExecutionConsoleTest : BasePlatformTestCase() {
}
-
-internal class Chunk(val text: String, val style: TextStyle)
-
-internal class ScreenText(val chunks: List) {
-
- fun contains(text: String): Boolean = chunks.any { it.text.contains(text) }
-
- fun contains(chunksToFind: Chunk): Boolean = chunks.any {
- it.text == chunksToFind.text && it.style == chunksToFind.style
- }
-
- companion object {
- fun collect(textBuffer: TerminalTextBuffer): ScreenText {
- val result: List = textBuffer.screenLinesStorage.flatMap {
- it.entries.map { entry ->
- Chunk(entry.text.toString(), entry.style)
- }
- }
- return ScreenText(result)
- }
- }
-}
-
-internal object MockPtyBasedProcess : Process(), PtyBasedProcess {
-
- const val EXIT_CODE = 123
-
- private val exitCodeFuture: CompletableFuture = CompletableFuture()
-
- override fun destroy() {
- exitCodeFuture.complete(EXIT_CODE)
- }
-
- override fun waitFor(): Int = exitCodeFuture.get()
-
- override fun exitValue(): Int {
- return exitCodeFuture.getNow(null) ?: throw IllegalThreadStateException()
- }
-
- override fun getOutputStream(): OutputStream = OutputStream.nullOutputStream()
- override fun getErrorStream(): InputStream = InputStream.nullInputStream()
- override fun getInputStream(): InputStream = InputStream.nullInputStream()
-
- override fun hasPty(): Boolean = true
-
- override fun setWindowSize(columns: Int, rows: Int) {}
-}
+private val DEFAULT_TEST_TIMEOUT: Duration = 60.seconds
diff --git a/platform/execution-impl/testSources/com/intellij/terminal/TestJavaMainClassCommand.kt b/platform/execution-impl/testSources/com/intellij/terminal/TestJavaMainClassCommand.kt
new file mode 100644
index 000000000000..b933b8dfd7c6
--- /dev/null
+++ b/platform/execution-impl/testSources/com/intellij/terminal/TestJavaMainClassCommand.kt
@@ -0,0 +1,43 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.terminal
+
+import com.intellij.openapi.application.PathManager
+import com.intellij.platform.eel.EelExecApiHelpers
+import com.intellij.platform.eel.pathSeparator
+import com.intellij.platform.eel.provider.LocalEelDescriptor
+import com.intellij.platform.eel.provider.localEel
+import com.intellij.platform.eel.spawnProcess
+import com.intellij.util.execution.ParametersListUtil
+import java.nio.file.Path
+import kotlin.io.path.Path
+
+internal class TestJavaMainClassCommand(
+ private val mainClass: Class<*>,
+ dependencies: List>,
+ private val args: List,
+) {
+ private val javaExe: Path = Path(ProcessHandle.current().info().command().get())
+ private val classPath: String = getClassPath(mainClass, dependencies)
+
+ val commandLine: String
+ get() = ParametersListUtil.join(listOf(javaExe.toString(), mainClass.canonicalName) + args)
+
+ fun createLocalProcessBuilder(): EelExecApiHelpers.SpawnProcess {
+ return localEel.exec.spawnProcess(javaExe.toString())
+ .env(mapOf("CLASSPATH" to classPath))
+ .args(listOf(mainClass.canonicalName) + args)
+ }
+
+ override fun toString(): String {
+ return "TestJavaMainClassCommand(mainClass=$mainClass, args=$args)"
+ }
+
+ private companion object {
+ private fun getClassPath(mainClass: Class<*>, dependencies: List>): String {
+ val classPathRoots = (listOf(mainClass, KotlinVersion::class.java /* kotlin-stdlib.jar */) + dependencies).map {
+ checkNotNull(PathManager.getJarPathForClass(it)) { "Cannot find jar/directory for $it" }
+ }.distinct()
+ return classPathRoots.joinToString(LocalEelDescriptor.osFamily.pathSeparator)
+ }
+ }
+}
diff --git a/platform/execution-impl/testSources/com/intellij/terminal/terminalTestExecution.kt b/platform/execution-impl/testSources/com/intellij/terminal/terminalTestExecution.kt
new file mode 100644
index 000000000000..9d10124ea6ed
--- /dev/null
+++ b/platform/execution-impl/testSources/com/intellij/terminal/terminalTestExecution.kt
@@ -0,0 +1,88 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.terminal
+
+import com.intellij.execution.process.KillableProcessHandler
+import com.intellij.execution.process.ProcessHandler
+import com.intellij.execution.process.ProcessTerminatedListener
+import com.intellij.execution.process.PtyBasedProcess
+import com.intellij.platform.eel.EelExecApi.Pty
+import com.intellij.platform.eel.ExecuteProcessException
+import com.intellij.util.io.BaseDataReader
+import com.intellij.util.io.BaseOutputReader
+import java.io.InputStream
+import java.io.OutputStream
+import java.util.concurrent.CompletableFuture
+
+internal suspend fun createTerminalProcessHandler(javaCommand: TestJavaMainClassCommand): ProcessHandler {
+ val eelProcess = try {
+ javaCommand.createLocalProcessBuilder()
+ .interactionOptions(Pty(80, 25, true))
+ .eelIt()
+ }
+ catch (err: ExecuteProcessException) {
+ throw IllegalStateException("Failed to start ${javaCommand.commandLine}", err)
+ }
+ return createTerminalProcessHandler(eelProcess.convertToJavaProcess(), javaCommand.commandLine)
+}
+
+private fun createTerminalProcessHandler(process: Process, commandLine: String): KillableProcessHandler {
+ val terminalOutputOptions = object : BaseOutputReader.Options() {
+ override fun policy(): BaseDataReader.SleepingPolicy = BaseDataReader.SleepingPolicy.BLOCKING
+ override fun splitToLines(): Boolean = false
+ override fun withSeparators(): Boolean = true
+ }
+ return object : KillableProcessHandler(process, commandLine, Charsets.UTF_8) {
+ override fun readerOptions(): BaseOutputReader.Options = terminalOutputOptions
+ }
+}
+
+internal fun ProcessHandler.writeToStdinAndHitEnter(input: String) {
+ processInput!!.let {
+ it.write((input + "\r").toByteArray(Charsets.UTF_8))
+ it.flush()
+ }
+}
+
+internal class MockPtyBasedProcess : Process(), PtyBasedProcess {
+
+ private val exitCodeFuture: CompletableFuture = CompletableFuture()
+
+ override fun destroy() {
+ exitCodeFuture.complete(EXIT_CODE)
+ }
+
+ override fun waitFor(): Int = exitCodeFuture.get()
+
+ override fun exitValue(): Int {
+ return exitCodeFuture.getNow(null) ?: throw IllegalThreadStateException()
+ }
+
+ override fun getOutputStream(): OutputStream = OutputStream.nullOutputStream()
+ override fun getErrorStream(): InputStream = InputStream.nullInputStream()
+ override fun getInputStream(): InputStream = InputStream.nullInputStream()
+
+ override fun hasPty(): Boolean = true
+
+ override fun setWindowSize(columns: Int, rows: Int) {}
+
+ companion object {
+ const val EXIT_CODE = 123
+ }
+}
+
+internal object TestProcessTerminationMessage {
+
+ private const val PROCESS_TERMINATED_MESSAGE = $$"Process finished with exit code $EXIT_CODE$"
+
+ fun attach(processHandler: ProcessHandler) {
+ ProcessTerminatedListener.attach(
+ processHandler,
+ null /* don't update the status bar */,
+ "\n" + PROCESS_TERMINATED_MESSAGE
+ )
+ }
+
+ fun getMessage(exitCode: Int): String {
+ return PROCESS_TERMINATED_MESSAGE.replace($$"$EXIT_CODE$", exitCode.toString())
+ }
+}
diff --git a/platform/execution-impl/testSources/com/intellij/terminal/terminalTestOutput.kt b/platform/execution-impl/testSources/com/intellij/terminal/terminalTestOutput.kt
new file mode 100644
index 000000000000..fd8bde1aaccd
--- /dev/null
+++ b/platform/execution-impl/testSources/com/intellij/terminal/terminalTestOutput.kt
@@ -0,0 +1,198 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.terminal
+
+import com.intellij.util.asSafely
+import com.jediterm.terminal.Terminal
+import com.jediterm.terminal.TextStyle
+import com.jediterm.terminal.model.CharBuffer
+import com.jediterm.terminal.model.TerminalLine
+import com.jediterm.terminal.model.TerminalModelListener
+import com.jediterm.terminal.model.TerminalTextBuffer
+import com.jediterm.terminal.util.CharUtils
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.withTimeout
+import org.assertj.core.api.Assertions
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+internal suspend fun TerminalExecutionConsole.awaitOutputEndsWithLines(
+ timeout: Duration = DEFAULT_OUTPUT_TIMEOUT,
+ expectedEndLines: List,
+) {
+ awaitTextBufferCondition(timeout, terminalWidget.terminalTextBuffer) {
+ val output = TerminalOutput.collect(terminalWidget)
+ output.assertEndsWithLines(expectedEndLines)
+ }
+}
+
+internal fun TerminalExecutionConsole.assertOutputStartsWithLines(expectedStartLines: List) {
+ val output = TerminalOutput.collect(terminalWidget)
+ output.assertStartsWithLines(expectedStartLines)
+}
+
+internal suspend fun TerminalExecutionConsole.awaitOutputContainsSubstring(
+ timeout: Duration = DEFAULT_OUTPUT_TIMEOUT,
+ substringToFind: String,
+) {
+ awaitTextBufferCondition(timeout, terminalWidget.terminalTextBuffer) {
+ val output = TerminalOutput.collect(terminalWidget)
+ output.assertContainsSubstring(substringToFind)
+ }
+}
+
+internal fun TerminalOutput.assertContainsSubstring(substringToFind: String) {
+ if (!contains(substringToFind)) {
+ Assertions.fail("Cannot find '$substringToFind' in the output: ${getTextLines()}")
+ }
+}
+
+internal fun TerminalOutput.assertContainsChunk(chunkToFind: TerminalOutputChunk) {
+ if (!this.contains(chunkToFind)) {
+ Assertions.fail("Cannot find '$chunkToFind' in the output: ${getTextLines()}")
+ }
+}
+
+internal fun TerminalOutput.assertLinesAre(expectedLines: List) {
+ val actualLines = getTextLines()
+ Assertions.assertThat(actualLines).isEqualTo(expectedLines)
+}
+
+private suspend fun awaitTextBufferCondition(
+ timeout: Duration,
+ textBuffer: TerminalTextBuffer,
+ assertCondition: () -> Unit,
+) {
+ val conditionHolds: () -> Boolean = {
+ runCatching { assertCondition() }.isSuccess
+ }
+ val conditionDeferred = CompletableDeferred()
+ val listener = TerminalModelListener {
+ if (conditionHolds()) {
+ conditionDeferred.complete(Unit)
+ }
+ }
+ textBuffer.addModelListener(listener)
+ try {
+ if (!conditionHolds()) {
+ withTimeout(timeout) {
+ conditionDeferred.await()
+ }
+ assertCondition()
+ }
+ }
+ catch (e: CancellationException) {
+ System.err.println(e.message)
+ assertCondition()
+ Assertions.fail(e)
+ }
+ finally {
+ textBuffer.removeModelListener(listener)
+ }
+}
+
+internal class TerminalOutput(val lines: List) {
+
+ fun contains(substringToFind: String): Boolean = lines.any { it.contains(substringToFind) }
+
+ fun contains(chunkToFind: TerminalOutputChunk): Boolean = lines.any {
+ it.contains(chunkToFind)
+ }
+
+ fun getTextLines(): List {
+ return lines.map { it.lineText }
+ }
+
+ companion object {
+ fun collect(terminalWidget: JBTerminalWidget): TerminalOutput {
+ val trimLineEnds = terminalWidget.ttyConnector.asSafely() != null
+ return collect(terminalWidget.terminalTextBuffer, terminalWidget.terminal, trimLineEnds)
+ }
+
+ private fun collect(textBuffer: TerminalTextBuffer, terminal: Terminal, trimLineEnds: Boolean): TerminalOutput {
+ return TerminalOutputBuilder(textBuffer, terminal, trimLineEnds).build()
+ }
+ }
+}
+
+internal fun TerminalOutput.assertEndsWithLines(expectedEndLines: List) {
+ val actualLines = getTextLines()
+ Assertions.assertThat(actualLines).endsWith(expectedEndLines.toTypedArray())
+}
+
+internal fun TerminalOutput.assertStartsWithLines(expectedStartLines: List) {
+ val actualLines = getTextLines()
+ Assertions.assertThat(actualLines).startsWith(*expectedStartLines.toTypedArray())
+}
+
+internal class TerminalOutputLine(val outputChunks: List) {
+
+ val lineText: String
+ get() = outputChunks.joinToString(separator = "") { it.text }
+
+ fun contains(substringToFind: String): Boolean = outputChunks.any {
+ it.text.contains(substringToFind)
+ }
+
+ fun contains(chunkToFind: TerminalOutputChunk): Boolean = outputChunks.any {
+ it.text == chunkToFind.text && it.style == chunkToFind.style
+ }
+
+ override fun toString(): String = lineText
+}
+
+internal data class TerminalOutputChunk(val text: String, val style: TextStyle)
+
+private class TerminalOutputBuilder(
+ private val textBuffer: TerminalTextBuffer,
+ private val terminal: Terminal,
+ private val trimLineEnds: Boolean,
+) {
+
+ private val lines: MutableList = mutableListOf()
+ private var currentLine: MutableList = mutableListOf()
+ private var previousLineWrapped: Boolean = true
+
+ fun build(): TerminalOutput {
+ textBuffer.modify {
+ val cursorLineInd = terminal.cursorPosition.y - 1
+ for (ind in -textBuffer.historyLinesCount .. cursorLineInd) {
+ addLine(textBuffer.getLine(ind))
+ }
+ }
+ lines.add(TerminalOutputLine(currentLine))
+ return TerminalOutput(if (trimLineEnds) lines.map { trimLineEnd(it) } else lines)
+ }
+
+ private fun addLine(line: TerminalLine) {
+ if (!previousLineWrapped) {
+ lines.add(TerminalOutputLine(currentLine))
+ currentLine = mutableListOf()
+ }
+ line.forEachEntry { entry ->
+ val text = entry.text.clearDWC()
+ if (text.isNotEmpty() && (!entry.isNul || entry.style != TextStyle.EMPTY)) {
+ val resultText = if (entry.isNul) " ".repeat(text.length) else text
+ currentLine.add(TerminalOutputChunk(resultText, entry.style))
+ }
+ }
+ previousLineWrapped = line.isWrapped
+ }
+
+}
+
+private fun trimLineEnd(line: TerminalOutputLine): TerminalOutputLine {
+ val chunks = line.outputChunks.toMutableList()
+ while (chunks.size > 1 && chunks.last().text.isBlank()) {
+ chunks.removeLast()
+ }
+ if (chunks.isNotEmpty()) {
+ val lastChunk = chunks.removeLast()
+ chunks.add(TerminalOutputChunk(lastChunk.text.trimEnd(), lastChunk.style))
+ }
+ return TerminalOutputLine(chunks)
+}
+
+private fun CharBuffer.clearDWC(): String = this.toString().replace(CharUtils.DWC.toString(), "")
+
+internal val DEFAULT_OUTPUT_TIMEOUT: Duration = 20.seconds
diff --git a/platform/execution-impl/testSources/com/intellij/terminal/testApp/SimpleCliApp.kt b/platform/execution-impl/testSources/com/intellij/terminal/testApp/SimpleCliApp.kt
new file mode 100644
index 000000000000..5513fe881827
--- /dev/null
+++ b/platform/execution-impl/testSources/com/intellij/terminal/testApp/SimpleCliApp.kt
@@ -0,0 +1,44 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.terminal.testApp
+
+import com.intellij.terminal.TestJavaMainClassCommand
+import kotlin.system.exitProcess
+
+internal object SimpleCliApp {
+ @JvmStatic
+ fun main(args: Array) {
+ val console = System.console() ?: error("No console available")
+ val options = readOptions(args)
+ val stdout = console.writer()
+ stdout.print(options.textToPrint)
+ stdout.flush()
+ if (options.readInputStringToExit != null) {
+ val stdin = console.reader().buffered()
+ do {
+ val line = stdin.readLine()
+ stdout.println("Read line: $line")
+ stdout.flush()
+ }
+ while (line != options.readInputStringToExit)
+ }
+ if (options.exitCode != 0) {
+ exitProcess(options.exitCode)
+ }
+ }
+
+ fun readOptions(args: Array): Options {
+ val textToPrint = args.getOrNull(0) ?: error("No textToPrint specified")
+ val exitCode = args.getOrNull(1)?.toIntOrNull() ?: error("No exitCode specified")
+ val readInputStringToExit = args.getOrNull(2)
+ return Options(textToPrint, exitCode, readInputStringToExit)
+ }
+
+ class Options(val textToPrint: String, val exitCode: Int, val readInputStringToExit: String?)
+
+ object NonRuntime {
+ fun createCommand(options: Options): TestJavaMainClassCommand = TestJavaMainClassCommand(
+ SimpleCliApp::class.java, emptyList(),
+ listOf(options.textToPrint, options.exitCode.toString()) + listOfNotNull(options.readInputStringToExit)
+ )
+ }
+}