[terminal console] WEB-75542 RIDER-131843 move system output (command line) to the scrollback buffer before processing output from the ConPTY process

(cherry picked from commit 7b2662a0a2ea80d737f848fe5096641e7007c21c)

IJ-CR-182732

GitOrigin-RevId: b69fc0acb1cf772a7c2d704c5c7e72130002cf7b
This commit is contained in:
Sergey Simonchik
2025-11-17 19:02:52 +01:00
committed by intellij-monorepo-bot
parent d97efa17e1
commit 4fe360ec7e
8 changed files with 503 additions and 110 deletions

View File

@@ -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

View File

@@ -19,5 +19,7 @@
<orderEntry type="module" module-name="intellij.platform.core.ui" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.jediterm.core" scope="TEST" />
<orderEntry type="module" module-name="intellij.libraries.jediterm.ui" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.eel" scope="TEST" />
<orderEntry type="module" module-name="intellij.platform.eel.provider" scope="TEST" />
</component>
</module>

View File

@@ -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. <p/>
* 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).
* <a href="https://github.com/microsoft/terminal/issues/919#issuecomment-494600135">More details</a>
* <br/>
* 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.
* <br/>
* 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;

View File

@@ -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 <T> 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 <T> 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<Chunk>) {
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<Chunk> = 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<Int> = 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

View File

@@ -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<Class<*>>,
private val args: List<String>,
) {
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<Class<*>>): 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)
}
}
}

View File

@@ -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<Int> = 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())
}
}

View File

@@ -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<String>,
) {
awaitTextBufferCondition(timeout, terminalWidget.terminalTextBuffer) {
val output = TerminalOutput.collect(terminalWidget)
output.assertEndsWithLines(expectedEndLines)
}
}
internal fun TerminalExecutionConsole.assertOutputStartsWithLines(expectedStartLines: List<String>) {
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<Unit>("Cannot find '$substringToFind' in the output: ${getTextLines()}")
}
}
internal fun TerminalOutput.assertContainsChunk(chunkToFind: TerminalOutputChunk) {
if (!this.contains(chunkToFind)) {
Assertions.fail<Unit>("Cannot find '$chunkToFind' in the output: ${getTextLines()}")
}
}
internal fun TerminalOutput.assertLinesAre(expectedLines: List<String>) {
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<Unit>()
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<TerminalOutputLine>) {
fun contains(substringToFind: String): Boolean = lines.any { it.contains(substringToFind) }
fun contains(chunkToFind: TerminalOutputChunk): Boolean = lines.any {
it.contains(chunkToFind)
}
fun getTextLines(): List<String> {
return lines.map { it.lineText }
}
companion object {
fun collect(terminalWidget: JBTerminalWidget): TerminalOutput {
val trimLineEnds = terminalWidget.ttyConnector.asSafely<ProcessHandlerTtyConnector>() != 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<String>) {
val actualLines = getTextLines()
Assertions.assertThat(actualLines).endsWith(expectedEndLines.toTypedArray())
}
internal fun TerminalOutput.assertStartsWithLines(expectedStartLines: List<String>) {
val actualLines = getTextLines()
Assertions.assertThat(actualLines).startsWith(*expectedStartLines.toTypedArray<String>())
}
internal class TerminalOutputLine(val outputChunks: List<TerminalOutputChunk>) {
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<TerminalOutputLine> = mutableListOf()
private var currentLine: MutableList<TerminalOutputChunk> = 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

View File

@@ -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<String>) {
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<String>): 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)
)
}
}