vibe/tests/cli/textual_ui/test_tool_result_link.py
Mathias Gesbert d50704e694
v2.18.2 (#857)
Co-authored-by: Cyprien <courtot.c@gmail.com>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Peter Evers <peter.evers@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Yousria <yousria.debaud@mistral.ai>
Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
2026-06-29 17:35:54 +02:00

272 lines
9.2 KiB
Python

from __future__ import annotations
import pytest
from textual.app import App, ComposeResult
from vibe.cli.textual_ui.widgets.links import (
LinkStatic,
link_content,
linkify_urls_in_text,
)
from vibe.cli.textual_ui.widgets.tool_widgets import WebSearchResultWidget
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource
def _click_actions(content: object) -> list[str]:
spans = getattr(content, "spans", [])
return [
span.style.meta["@click"]
for span in spans
if span.style.meta and "@click" in span.style.meta
]
def test_link_content_encodes_url_in_action_and_keeps_label() -> None:
# The action arg is percent-encoded; the visible label is the page name.
content = link_content("Example", "https://example.com")
assert content.plain == "Example"
assert _click_actions(content) == ["open_url('https%3A%2F%2Fexample.com')"]
def test_link_content_only_links_http_schemes() -> None:
# Non-http(s) schemes render as the plain label, with no clickable @click span.
for url in ("file:///etc/passwd", "javascript:alert(1)", "vscode://x"):
content = link_content(url, url)
assert content.plain == url
assert _click_actions(content) == []
def test_link_content_handles_previously_unsafe_urls() -> None:
# Brackets, quotes, parens live in the encoded action, never in the label text.
for url in (
"https://e.org/x[1]",
"https://e.org/it's",
"https://e.org/x)",
"https://en.wikipedia.org/wiki/Python_(programming_language)",
):
content = link_content(url, url)
assert content.plain == url # literal text, never re-parsed as markup
assert len(_click_actions(content)) == 1
class _Harness(App):
def __init__(self, url: str = "https://example.com") -> None:
super().__init__()
self.url = url
self.opened: list[str] = []
def compose(self) -> ComposeResult:
yield LinkStatic(link_content(self.url, self.url))
def open_url(self, url: str, *, new_tab: bool = True) -> None:
self.opened.append(url)
@pytest.mark.asyncio
async def test_clicking_link_span_opens_url() -> None:
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause(0.1)
await pilot.click(LinkStatic)
await pilot.pause(0.1)
assert app.opened == ["https://example.com"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"https://en.wikipedia.org/wiki/Python_(programming_language)",
"https://httpbin.org/anything/test[1]",
"https://e.org/it's",
],
)
async def test_clicking_decodes_back_to_original_url(url: str) -> None:
app = _Harness(url)
async with app.run_test() as pilot:
await pilot.pause(0.1)
await pilot.click(LinkStatic)
await pilot.pause(0.1)
assert app.opened == [url]
@pytest.mark.asyncio
async def test_action_open_url_ignores_non_http_scheme() -> None:
from urllib.parse import quote
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause(0.1)
app.query_one(LinkStatic).action_open_url(quote("file:///etc/passwd", safe=""))
await pilot.pause(0.1)
assert app.opened == []
@pytest.mark.asyncio
@pytest.mark.parametrize("scheme", ["javascript", "file", "data"])
async def test_unsafe_schemes_are_rejected(scheme: str) -> None:
app = _Harness(f"{scheme}:payload")
async with app.run_test() as pilot:
await pilot.pause(0.1)
await pilot.click(LinkStatic)
await pilot.pause(0.1)
assert app.opened == []
def test_linkify_urls_in_text_auto_detects_url() -> None:
# Once a tool is opted into linkification, URLs are found in the message
# itself — the call site doesn't have to point at the URL span.
content = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)")
assert content.plain == "Fetched https://example.com (10 chars, text/html)"
assert _click_actions(content) == ["open_url('https%3A%2F%2Fexample.com')"]
def test_linkify_urls_in_text_handles_multiple_urls() -> None:
content = linkify_urls_in_text("see https://a.com and https://b.com here")
assert _click_actions(content) == [
"open_url('https%3A%2F%2Fa.com')",
"open_url('https%3A%2F%2Fb.com')",
]
def test_linkify_urls_in_text_keeps_balanced_parens_in_url() -> None:
# Wikipedia-style URLs with `(…)` were the reason the @click action is
# percent-encoded; Rich's URL detector already keeps them in the span.
url = "https://en.wikipedia.org/wiki/Python_(programming_language)"
content = linkify_urls_in_text(f"see {url} for details")
assert url in content.plain
assert _click_actions(content) == [
"open_url('https%3A%2F%2Fen.wikipedia.org%2Fwiki%2F"
"Python_%28programming_language%29')"
]
def test_linkify_urls_in_text_keeps_brackets_literal_when_no_url() -> None:
# Raw tool text with brackets stays literal (Content is never markup-parsed).
content = linkify_urls_in_text("Searched '[a]' (2 sources)")
assert content.plain == "Searched '[a]' (2 sources)"
assert _click_actions(content) == []
async def _rendered_lines(widget: WebSearchResultWidget) -> list[str]:
from textual.widgets import Static
class _H(App):
def compose(self) -> ComposeResult:
yield widget
async with _H().run_test() as pilot:
await pilot.pause(0.1)
return [str(w.render()) for w in widget.query(Static)]
@pytest.mark.asyncio
async def test_websearch_single_source_is_bulleted_without_header() -> None:
result = WebSearchResult(
query="uv",
answer="ans",
sources=[WebSearchSource(title="Docs", url="https://x.com")],
)
lines = await _rendered_lines(
WebSearchResultWidget(result, success=True, message="m")
)
joined = "\n".join(lines)
assert "• Docs" in joined # bulleted, page name as the link label
assert "https://x.com" not in joined # url lives in the @click action, not text
assert "Source:" not in joined # singular "Source:" prefix dropped
assert "Sources:" not in joined # no header for a lone source
assert "WebSearchSource(" not in joined # raw `sources: [...]` dump dropped
@pytest.mark.asyncio
async def test_websearch_multiple_sources_is_bulleted_plural() -> None:
result = WebSearchResult(
query="uv",
answer="ans",
sources=[
WebSearchSource(title="A", url="https://a.com"),
WebSearchSource(title="B", url="https://b.com"),
],
)
lines = await _rendered_lines(
WebSearchResultWidget(result, success=True, message="m")
)
joined = "\n".join(lines)
assert "Sources:" in joined
assert "• A" in joined
assert "• B" in joined
assert "https://a.com" not in joined # url lives in the @click action, not text
assert "WebSearchSource(" not in joined
@pytest.mark.asyncio
async def test_tool_call_message_set_result_text_renders_clickable_url() -> None:
call = ToolCallMessage(tool_name="web_fetch")
class _H(App):
def compose(self) -> ComposeResult:
yield call
async with _H().run_test() as pilot:
await pilot.pause(0.1)
call.set_result_text(
"Fetched https://example.com (10 chars, text/html)", linkify=True
)
await pilot.pause(0.1)
rendered = str(call._text_widget.render()) if call._text_widget else ""
# The URL becomes a clickable span in the status line; surrounding text stays.
assert "https://example.com" in rendered
assert "Fetched" in rendered
@pytest.mark.asyncio
async def test_tool_call_message_set_result_text_keeps_brackets_literal_off() -> None:
call = ToolCallMessage(tool_name="bash")
class _H(App):
def compose(self) -> ComposeResult:
yield call
async with _H().run_test() as pilot:
await pilot.pause(0.1)
# Bash isn't in the linkify whitelist; URLs must stay plain text and
# brackets in the message must not be interpreted as markup.
call.set_result_text("ran: see https://example.com [exit 0]")
await pilot.pause(0.1)
rendered = str(call._text_widget.render()) if call._text_widget else ""
assert "@click=open_url" not in rendered
assert "https://example.com" in rendered
assert "[exit 0]" in rendered
@pytest.mark.asyncio
@pytest.mark.parametrize("linkify", [False, True])
async def test_tool_call_message_renders_malformed_markup_without_crashing(
linkify: bool,
) -> None:
# A bash summary like `git tag [/foo bar]` reads as a broken Rich closing
# tag and used to raise MarkupError when rendered into a markup=True widget.
call = ToolCallMessage(tool_name="bash")
class _H(App):
def compose(self) -> ComposeResult:
yield call
text = "ran: git tag [/foo bar] && echo [done] https://example.com"
async with _H().run_test() as pilot:
await pilot.pause(0.1)
call.set_result_text(text, linkify=linkify)
await pilot.pause(0.1)
rendered = str(call._text_widget.render()) if call._text_widget else ""
assert "[/foo bar]" in rendered
assert "[done]" in rendered