[terminal] IJPL-252435 Add more end-to-end tests for OSC8 hyperlinks

(cherry picked from commit d0cb7d941b74b23105d7ab9cd2d90f8088e3c170)

IJ-CR-217674

GitOrigin-RevId: 77f3d7424983e4c2712e593f22126ce27722eca7
This commit is contained in:
Konstantin Hudyakov
2026-08-10 18:43:20 +00:00
committed by intellij-monorepo-bot
parent 06b4d9d535
commit be24d5d204
@@ -19,9 +19,11 @@ 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.jetbrains.plugins.terminal.view.TerminalOutputModel
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.awt.event.MouseEvent
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -37,11 +39,54 @@ 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 ->
fun `OSC8 hyperlink text can differ from its target URI`(): Unit = doTest { fixture ->
fixture.connector.feed("before ${osc8("https://example.com", "link text")} after")
val highlighter = fixture.awaitHyperlinkHighlighter()
val highlighter = fixture.awaitHyperlink()
assertThat(fixture.textOf(highlighter)).isEqualTo("link text")
assertThat(fixture.uriOf(highlighter)).isEqualTo("https://example.com")
}
@Test
fun `several OSC8 hyperlinks in the same output are each rendered separately`(): Unit = doTest { fixture ->
fixture.connector.feed("${osc8("https://jetbrains.com", "FIRST")} middle ${osc8("https://example.com", "SECOND")}")
val highlighters = fixture.awaitHyperlinks(2)
assertThat(highlighters.map { fixture.textOf(it) }).containsExactly("FIRST", "SECOND")
assertThat(highlighters.map { fixture.uriOf(it) }).containsExactly("https://jetbrains.com", "https://example.com")
}
@Test
fun `a target that is not a recognized URL is not rendered as a hyperlink`(): Unit = doTest { fixture ->
fixture.connector.feed(osc8("definitely-not-a-url", "click me"))
// A real link fed right after: once it's rendered, reconciliation has scanned the whole link list
// (including the one above) at least once, so a missing highlighter for "click me" isn't just a timing fluke.
fixture.connector.feed(osc8("https://jetbrains.com", "SENTINEL"))
val highlighters = fixture.awaitHyperlinks(1)
assertThat(fixture.textOf(highlighters.single())).isEqualTo("SENTINEL")
}
@Test
fun `hyperlink is removed from the markup model once its line is overwritten with plain text`(): Unit = doTest { fixture ->
fixture.connector.feed(osc8("https://jetbrains.com", "LINK"))
fixture.awaitHyperlink()
// Move the cursor back to the start of the line and overwrite it with plain text at least as long as "LINK".
fixture.connector.feed("\rplain text, no links here")
fixture.awaitHyperlinks(0)
}
@Test
fun `hovering an OSC8 hyperlink shows its target URI as a tooltip`(): Unit = doTest { fixture ->
fixture.connector.feed("x ${osc8("https://jetbrains.com", "JB")} y")
val highlighter = fixture.awaitHyperlink()
fixture.hover(highlighter)
// The link text ("JB") says nothing about the destination - the tooltip is what reveals it.
assertThat(fixture.editor.contentComponent.toolTipText).contains("https://jetbrains.com")
}
private fun doTest(test: suspend (Fixture) -> Unit) {
@@ -70,6 +115,8 @@ internal class TerminalOsc8HyperlinksEndToEndTest : BasePlatformTestCase() {
val terminalView = TerminalViewImpl(project, JBTerminalSystemSettingsProvider(), null, scope)
terminalView.connectToSession(session)
editor = terminalView.outputEditor as EditorImpl
// So offsetToXY (used by hover()) produces valid screen coordinates in a headless test.
editor.component.setSize(800, 600)
}
fun textOf(highlighter: RangeHighlighter): String {
@@ -77,16 +124,39 @@ internal class TerminalOsc8HyperlinksEndToEndTest : BasePlatformTestCase() {
}
/**
* 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.
* The target URI of the OSC8 link rendered as [highlighter], read from the output model - the
* markup model's own decoration doesn't expose it (it's only used internally to build the click action).
*/
suspend fun awaitHyperlinkHighlighter(): RangeHighlighter {
fun uriOf(highlighter: RangeHighlighter): String {
val model = editor.getUserData(TerminalOutputModel.KEY)!!
return model.getOsc8Hyperlinks().single {
(it.startOffset - model.startOffset).toInt() == highlighter.startOffset &&
(it.endOffset - model.startOffset).toInt() == highlighter.endOffset
}.uri
}
/** Moves the mouse over the middle of [highlighter]'s range, as a real mouse move would. */
fun hover(highlighter: RangeHighlighter) {
val offset = (highlighter.startOffset + highlighter.endOffset) / 2
val point = editor.offsetToXY(offset)
val event = MouseEvent(
editor.contentComponent, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, point.x, point.y, 1, false, MouseEvent.BUTTON1
)
editor.contentComponent.mouseMotionListeners.forEach { it.mouseMoved(event) }
}
suspend fun awaitHyperlink(): RangeHighlighter = awaitHyperlinks(1).single()
/**
* Polls the output editor's markup model until exactly [count] hyperlink highlighters are present,
* then returns them sorted by position.
*/
suspend fun awaitHyperlinks(count: Int): List<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()
if (highlighters.size == count) return highlighters.sortedBy { it.startOffset }
delay(50.milliseconds)
}
}