From 06b4d9d535e19ee15732fc4dea41daaad022da15 Mon Sep 17 00:00:00 2001 From: Konstantin Hudyakov Date: Sat, 8 Aug 2026 10:52:42 +0300 Subject: [PATCH] [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 --- .../frontend/session/TerminalSessionImpl.kt | 39 +++--- .../TerminalOsc8HyperlinksEndToEndTest.kt | 113 ------------------ .../TerminalOsc8HyperlinksEndToEndTest.kt | 108 +++++++++++++++++ 3 files changed, 128 insertions(+), 132 deletions(-) delete mode 100644 plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/backend/TerminalOsc8HyperlinksEndToEndTest.kt create mode 100644 plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/frontend/TerminalOsc8HyperlinksEndToEndTest.kt diff --git a/plugins/terminal/frontend/src/com/intellij/terminal/frontend/session/TerminalSessionImpl.kt b/plugins/terminal/frontend/src/com/intellij/terminal/frontend/session/TerminalSessionImpl.kt index 4b1b3976ce47..08319dd9414c 100644 --- a/plugins/terminal/frontend/src/com/intellij/terminal/frontend/session/TerminalSessionImpl.kt +++ b/plugins/terminal/frontend/src/com/intellij/terminal/frontend/session/TerminalSessionImpl.kt @@ -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 } diff --git a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/backend/TerminalOsc8HyperlinksEndToEndTest.kt b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/backend/TerminalOsc8HyperlinksEndToEndTest.kt deleted file mode 100644 index 2b5fc94baecd..000000000000 --- a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/backend/TerminalOsc8HyperlinksEndToEndTest.kt +++ /dev/null @@ -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 { 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().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\\" - } -} diff --git a/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/frontend/TerminalOsc8HyperlinksEndToEndTest.kt b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/frontend/TerminalOsc8HyperlinksEndToEndTest.kt new file mode 100644 index 000000000000..f48d948fc87b --- /dev/null +++ b/plugins/terminal/tests/src/com/intellij/terminal/tests/reworked/frontend/TerminalOsc8HyperlinksEndToEndTest.kt @@ -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\\" + } +}