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 index f48d948fc87b..e91455748342 100644 --- 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 @@ -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 { 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) } }