mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
[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:
committed by
intellij-monorepo-bot
parent
152ae62331
commit
06b4d9d535
+20
-19
@@ -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
|
||||
}
|
||||
|
||||
|
||||
-113
@@ -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\\"
|
||||
}
|
||||
}
|
||||
+108
@@ -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\\"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user