[terminal] IJPL-252435 Rework OSC8 hyperlink end-to-end tests

Make them really end-to-end: span from the tty connector till hyperlink rendering in the markup model of the editor.

(cherry picked from commit 5107db6faa13ef3689ce583b27d526af84f96eba)

IJ-CR-217674

GitOrigin-RevId: 8b0ef8eefcb7360c052311d4cb42719f4301b2bd
This commit is contained in:
Konstantin Hudyakov
2026-08-10 18:43:20 +00:00
committed by intellij-monorepo-bot
parent 152ae62331
commit 06b4d9d535
3 changed files with 128 additions and 132 deletions
@@ -36,29 +36,30 @@ internal class TerminalSessionImpl(
}
}
override val eelDescriptor: EelDescriptor
/**
* Falls back to [LocalEelDescriptor] when [ttyConnector] isn't a [LocalTerminalTtyConnector].
* In production, [ttyConnector] is always a [LocalTerminalTtyConnector].
* The fallback is test-only, exercised by tests driving the session through a fake connector.
*/
get() = (ttyConnector.original as? LocalTerminalTtyConnector)?.eelDescriptor ?: LocalEelDescriptor
private var missingLocalTtyConnectorLogged = false
private val localTtyConnector: LocalTerminalTtyConnector?
/**
* In production, [ttyConnector] is always a [LocalTerminalTtyConnector]; on a miss, [eelDescriptor]
* and [processId] return guesses, hence `LOG.error`.
* In tests with a fake connector (see `LoopbackTtyConnector`), `LOG.error` fails
* tests that rely on these getters.
*/
get() = ttyConnector.original as? LocalTerminalTtyConnector ?: run {
if (!missingLocalTtyConnectorLogged) {
missingLocalTtyConnectorLogged = true
LOG.error("Unable to find LocalTerminalTtyConnector in $ttyConnector")
}
null
}
override val eelDescriptor: EelDescriptor
get() = localTtyConnector?.eelDescriptor ?: LocalEelDescriptor
override val processId: Long
/**
* In production, [ttyConnector] is always a [LocalTerminalTtyConnector].
* Miss can happen only in tests where a fake connector is used, so `LOG.error` to fail the test.
*/
get() {
val localTtyConnector = localTtyConnector ?: return -1
val localTtyConnector = ttyConnector.original as? LocalTerminalTtyConnector
if (localTtyConnector == null) {
if (!missingLocalTtyConnectorLogged) {
missingLocalTtyConnectorLogged = true
LOG.error("Unable to find LocalTerminalTtyConnector in $ttyConnector")
}
return -1
}
return localTtyConnector.shellEelProcess.eelProcess.pid.value
}
@@ -1,113 +0,0 @@
// 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.tests.reworked.backend
import com.intellij.openapi.application.EDT
import com.intellij.platform.util.coroutines.childScope
import com.intellij.terminal.tests.reworked.util.LoopbackTtyConnector
import com.intellij.terminal.tests.reworked.util.TerminalOutputEventCollector
import com.intellij.terminal.tests.reworked.util.TerminalSessionTestUtil
import com.intellij.terminal.tests.reworked.util.TerminalTestUtil
import com.intellij.terminal.tests.reworked.util.awaitEvent
import com.intellij.testFramework.DisposableRule
import com.intellij.testFramework.ProjectRule
import com.intellij.testFramework.RuleChain
import com.intellij.testFramework.common.timeoutRunBlocking
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.plugins.terminal.session.impl.TerminalContentUpdatedEvent
import org.jetbrains.plugins.terminal.session.impl.TerminalSession
import org.jetbrains.plugins.terminal.view.TerminalOutputModel
import org.jetbrains.plugins.terminal.view.TerminalOutputOsc8Hyperlink
import org.jetbrains.plugins.terminal.view.impl.updateContent
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* End-to-end coverage of OSC8 hyperlinks for the JediTerm engine: raw `OSC 8` escape sequences are written to a
* [LoopbackTtyConnector] and go through the production `createTerminalSession` (real emulation +
* `JediTermOsc8HyperlinkFilter`), and the resulting [TerminalContentUpdatedEvent]s are asserted both directly and
* after being applied to a [org.jetbrains.plugins.terminal.view.impl.MutableTerminalOutputModel].
*
* This exercises the whole producer chain that the more targeted unit tests intentionally bypass:
* emulator OSC8 parsing -> hyperlink filter -> scraper span extraction -> event serialization -> model application.
*/
@RunWith(JUnit4::class)
internal class TerminalOsc8HyperlinksEndToEndTest {
private val projectRule = ProjectRule()
private val disposableRule = DisposableRule()
@Rule
@JvmField
val ruleChain: RuleChain = RuleChain(projectRule, disposableRule)
@Test
fun `OSC8 hyperlink is reported on the content update event`() = runSessionTest { session, connector ->
val collector = TerminalOutputEventCollector(session, this)
connector.feed("x ${osc8("https://jetbrains.com", "JB")} y")
val event = collector.awaitEvent<TerminalContentUpdatedEvent> { it.osc8Hyperlinks.isNotEmpty() }
val link = event.osc8Hyperlinks.single()
assertThat(link.uri).isEqualTo("https://jetbrains.com")
assertThat(event.text.substring(link.startOffset.toInt(), link.endOffset.toInt())).isEqualTo("JB")
}
@Test
fun `OSC8 hyperlink is stored in the output model after applying the events`() = runSessionTest { session, connector ->
val model = TerminalTestUtil.createOutputModel()
launch(Dispatchers.EDT) {
session.getOutputFlow().collect { events ->
events.filterIsInstance<TerminalContentUpdatedEvent>().forEach { model.updateContent(it) }
}
}
connector.feed("before ${osc8("https://example.com", "link text")} after")
val link = awaitOsc8Hyperlink(model)
assertThat(link.uri).isEqualTo("https://example.com")
assertThat(model.getText(link.startOffset, link.endOffset).toString()).isEqualTo("link text")
}
private fun runSessionTest(
test: suspend CoroutineScope.(session: TerminalSession, connector: LoopbackTtyConnector) -> Unit,
) {
timeoutRunBlocking(20.seconds) {
val sessionScope = childScope("TerminalSession")
try {
val (session, connector) = TerminalSessionTestUtil.createLoopbackTerminalSession(projectRule.project, sessionScope)
sessionScope.test(session, connector)
}
finally {
sessionScope.cancel()
}
}
}
private suspend fun awaitOsc8Hyperlink(model: TerminalOutputModel): TerminalOutputOsc8Hyperlink {
while (true) {
val link = withContext(Dispatchers.EDT) { model.getOsc8Hyperlinks().firstOrNull() }
if (link != null) return link
delay(50.milliseconds)
}
}
private fun osc8(uri: String, text: String): String = "$OSC8_PREFIX$uri$ST$text$OSC8_PREFIX$ST"
companion object {
private val ESC: String = Char(0x1B).toString()
/** OSC 8 introducer with empty params: `ESC ] 8 ; ;`. */
private val OSC8_PREFIX: String = "$ESC]8;;"
/** String Terminator: `ESC \`. */
private val ST: String = "$ESC\\"
}
}
@@ -0,0 +1,108 @@
// 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.tests.reworked.frontend
import com.intellij.openapi.application.EDT
import com.intellij.openapi.editor.impl.EditorImpl
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.openapi.project.Project
import com.intellij.platform.util.coroutines.childScope
import com.intellij.terminal.frontend.view.impl.TerminalViewImpl
import com.intellij.terminal.tests.reworked.util.LoopbackTtyConnector
import com.intellij.terminal.tests.reworked.util.TerminalSessionTestUtil
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.plugins.terminal.JBTerminalSystemSettingsProvider
import org.jetbrains.plugins.terminal.util.terminalProjectScope
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* End-to-end coverage of OSC8 hyperlinks: a real [TerminalViewImpl] is connected to the
* production [org.jetbrains.plugins.terminal.session.impl.TerminalSession], backed by a [LoopbackTtyConnector]
* instead of a real shell process. Raw `OSC 8` escape sequences are fed through the connector, and the final
* state is asserted where the UI actually renders it: a hyperlink [RangeHighlighter] in the output editor's
* markup model.
*/
@RunWith(JUnit4::class)
internal class TerminalOsc8HyperlinksEndToEndTest : BasePlatformTestCase() {
override fun runInDispatchThread(): Boolean = false
@Test
fun `OSC8 hyperlink is rendered as a hyperlink highlighter in the editor markup model`(): Unit = doTest { fixture ->
fixture.connector.feed("before ${osc8("https://example.com", "link text")} after")
val highlighter = fixture.awaitHyperlinkHighlighter()
assertThat(fixture.textOf(highlighter)).isEqualTo("link text")
}
private fun doTest(test: suspend (Fixture) -> Unit) {
return timeoutRunBlocking(20.seconds, context = Dispatchers.EDT) {
Fixture(project).use { fixture -> test(fixture) }
}
}
private fun osc8(uri: String, text: String): String = "$OSC8_PREFIX$uri$ST$text$OSC8_PREFIX$ST"
/**
* A real [TerminalViewImpl] connected to the production `TerminalSession`, backed by a [LoopbackTtyConnector],
* so the whole OSC8 pipeline - emulation, scraping, output model application and markup rendering
* (registered on [TerminalViewImpl.outputEditor]) - runs exactly as in production.
*/
private class Fixture(project: Project) : AutoCloseable {
private val scope = terminalProjectScope(project).childScope("TerminalViewImpl")
val connector: LoopbackTtyConnector
val editor: EditorImpl
init {
val (session, connector) = TerminalSessionTestUtil.createLoopbackTerminalSession(project, scope)
this.connector = connector
val terminalView = TerminalViewImpl(project, JBTerminalSystemSettingsProvider(), null, scope)
terminalView.connectToSession(session)
editor = terminalView.outputEditor as EditorImpl
}
fun textOf(highlighter: RangeHighlighter): String {
return editor.document.getText(highlighter.textRange)
}
/**
* Polls the output editor's markup model until exactly one hyperlink highlighter is present.
* Reconciliation between the output model and the markup model runs on its own delay
* (see `installOsc8HyperlinksProcessing`), not synchronously with every output model change.
*/
suspend fun awaitHyperlinkHighlighter(): RangeHighlighter {
while (true) {
val highlighters = withContext(Dispatchers.EDT) {
editor.markupModel.allHighlighters.filter { it.isValid && it.layer == HighlighterLayer.HYPERLINK }
}
if (highlighters.size == 1) return highlighters.single()
delay(50.milliseconds)
}
}
override fun close() {
scope.cancel()
}
}
companion object {
private val ESC: String = Char(0x1B).toString()
/** OSC 8 introducer with empty params: `ESC ] 8 ; ;`. */
private val OSC8_PREFIX: String = "$ESC]8;;"
/** String Terminator: `ESC \`. */
private val ST: String = "$ESC\\"
}
}