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) + ) + } +}