mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-03-22 15:19:59 +07:00
[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:
committed by
intellij-monorepo-bot
parent
d97efa17e1
commit
4fe360ec7e
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user