Initial commit

Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Laure Hugo <laure.hugo@mistral.ai>
Co-Authored-By: Benjamin Trom <benjamin.trom@mistral.ai>
Co-Authored-By: Mathias Gesbert <mathias.gesbert@ext.mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Valentin Berard <val@mistral.ai>
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin Torroba 2025-12-09 13:13:22 +01:00 committed by Quentin Torroba
commit fa15fc977b
200 changed files with 30484 additions and 0 deletions

86
tests/tools/test_bash.py Normal file
View file

@ -0,0 +1,86 @@
from __future__ import annotations
import pytest
from vibe.core.tools.base import BaseToolState, ToolError, ToolPermission
from vibe.core.tools.builtins.bash import Bash, BashArgs, BashToolConfig
@pytest.fixture
def bash(tmp_path):
config = BashToolConfig(workdir=tmp_path)
return Bash(config=config, state=BaseToolState())
@pytest.mark.asyncio
async def test_runs_echo_successfully(bash):
result = await bash.run(BashArgs(command="echo hello"))
assert result.returncode == 0
assert result.stdout == "hello\n"
assert result.stderr == ""
@pytest.mark.asyncio
async def test_fails_cat_command_with_missing_file(bash):
with pytest.raises(ToolError) as err:
await bash.run(BashArgs(command="cat missing_file.txt"))
message = str(err.value)
assert "Command failed" in message
assert "Return code: 1" in message
assert "No such file or directory" in message
@pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path):
config = BashToolConfig(workdir=tmp_path)
bash_tool = Bash(config=config, state=BaseToolState())
result = await bash_tool.run(BashArgs(command="pwd"))
assert result.stdout.strip() == str(tmp_path)
@pytest.mark.asyncio
async def test_handles_timeout(bash):
with pytest.raises(ToolError) as err:
await bash.run(BashArgs(command="sleep 2", timeout=1))
assert "Command timed out after 1s" in str(err.value)
@pytest.mark.asyncio
async def test_truncates_output_to_max_bytes(bash):
config = BashToolConfig(workdir=None, max_output_bytes=5)
bash_tool = Bash(config=config, state=BaseToolState())
result = await bash_tool.run(BashArgs(command="printf 'abcdefghij'"))
assert result.stdout == "abcde"
assert result.stderr == ""
assert result.returncode == 0
@pytest.mark.asyncio
async def test_decodes_non_utf8_bytes(bash):
result = await bash.run(BashArgs(command="printf '\\xff\\xfe'"))
# accept both possible encodings, as some shells emit escaped bytes as literal strings
assert result.stdout in {"<EFBFBD><EFBFBD>", "\xff\xfe", r"\xff\xfe"}
assert result.stderr == ""
def test_check_allowlist_denylist():
config = BashToolConfig(allowlist=["echo", "pwd"], denylist=["rm"])
bash_tool = Bash(config=config, state=BaseToolState())
allowlisted = bash_tool.check_allowlist_denylist(BashArgs(command="echo hi"))
denylisted = bash_tool.check_allowlist_denylist(BashArgs(command="rm -rf /tmp"))
mixed = bash_tool.check_allowlist_denylist(BashArgs(command="pwd && whoami"))
empty = bash_tool.check_allowlist_denylist(BashArgs(command=""))
assert allowlisted is ToolPermission.ALWAYS
assert denylisted is ToolPermission.NEVER
assert mixed is None
assert empty is None

347
tests/tools/test_grep.py Normal file
View file

@ -0,0 +1,347 @@
from __future__ import annotations
import shutil
import pytest
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.grep import (
Grep,
GrepArgs,
GrepBackend,
GrepState,
GrepToolConfig,
)
@pytest.fixture
def grep(tmp_path):
config = GrepToolConfig(workdir=tmp_path)
return Grep(config=config, state=GrepState())
@pytest.fixture
def grep_gnu_only(tmp_path, monkeypatch):
original_which = shutil.which
def mock_which(cmd):
if cmd == "rg":
return None
return original_which(cmd)
monkeypatch.setattr("shutil.which", mock_which)
config = GrepToolConfig(workdir=tmp_path)
return Grep(config=config, state=GrepState())
def test_detects_ripgrep_when_available(grep):
if shutil.which("rg"):
assert grep._detect_backend() == GrepBackend.RIPGREP
def test_falls_back_to_gnu_grep(grep, monkeypatch):
original_which = shutil.which
def mock_which(cmd):
if cmd == "rg":
return None
return original_which(cmd)
monkeypatch.setattr("shutil.which", mock_which)
if shutil.which("grep"):
assert grep._detect_backend() == GrepBackend.GNU_GREP
def test_raises_error_if_no_grep_available(grep, monkeypatch):
monkeypatch.setattr("shutil.which", lambda cmd: None)
with pytest.raises(ToolError) as err:
grep._detect_backend()
assert "Neither ripgrep (rg) nor grep is installed" in str(err.value)
@pytest.mark.asyncio
async def test_finds_pattern_in_file(grep, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n print('world')\n")
result = await grep.run(GrepArgs(pattern="hello"))
assert result.match_count == 1
assert "hello" in result.matches
assert "test.py" in result.matches
assert not result.was_truncated
@pytest.mark.asyncio
async def test_finds_multiple_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")
result = await grep.run(GrepArgs(pattern="foo"))
assert result.match_count == 3
assert result.matches.count("foo") == 3
assert not result.was_truncated
@pytest.mark.asyncio
async def test_returns_empty_on_no_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n pass\n")
result = await grep.run(GrepArgs(pattern="nonexistent"))
assert result.match_count == 0
assert result.matches == ""
assert not result.was_truncated
@pytest.mark.asyncio
async def test_fails_with_empty_pattern(grep):
with pytest.raises(ToolError) as err:
await grep.run(GrepArgs(pattern=""))
assert "Empty search pattern" in str(err.value)
@pytest.mark.asyncio
async def test_fails_with_nonexistent_path(grep):
with pytest.raises(ToolError) as err:
await grep.run(GrepArgs(pattern="test", path="nonexistent"))
assert "Path does not exist" in str(err.value)
@pytest.mark.asyncio
async def test_searches_in_specific_path(grep, tmp_path):
subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "test.py").write_text("match here\n")
(tmp_path / "other.py").write_text("match here too\n")
result = await grep.run(GrepArgs(pattern="match", path="subdir"))
assert result.match_count == 1
assert "subdir" in result.matches and "test.py" in result.matches
assert "other.py" not in result.matches
@pytest.mark.asyncio
async def test_truncates_to_max_matches(grep, tmp_path):
(tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))
result = await grep.run(GrepArgs(pattern="line", max_matches=50))
assert result.match_count == 50
assert result.was_truncated
@pytest.mark.asyncio
async def test_truncates_to_max_output_bytes(grep, tmp_path):
config = GrepToolConfig(workdir=tmp_path, max_output_bytes=100)
grep_tool = Grep(config=config, state=GrepState())
(tmp_path / "test.py").write_text("\n".join("x" * 100 for _ in range(10)))
result = await grep_tool.run(GrepArgs(pattern="x"))
assert len(result.matches) <= 100
assert result.was_truncated
@pytest.mark.asyncio
async def test_respects_default_ignore_patterns(grep, tmp_path):
(tmp_path / "included.py").write_text("match\n")
node_modules = tmp_path / "node_modules"
node_modules.mkdir()
(node_modules / "excluded.js").write_text("match\n")
result = await grep.run(GrepArgs(pattern="match"))
assert "included.py" in result.matches
assert "excluded.js" not in result.matches
@pytest.mark.asyncio
async def test_respects_vibeignore_file(grep, tmp_path):
(tmp_path / ".vibeignore").write_text("custom_dir/\n*.tmp\n")
custom_dir = tmp_path / "custom_dir"
custom_dir.mkdir()
(custom_dir / "excluded.py").write_text("match\n")
(tmp_path / "excluded.tmp").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
result = await grep.run(GrepArgs(pattern="match"))
assert "included.py" in result.matches
assert "excluded.py" not in result.matches
assert "excluded.tmp" not in result.matches
@pytest.mark.asyncio
async def test_ignores_comments_in_vibeignore(grep, tmp_path):
(tmp_path / ".vibeignore").write_text("# comment\npattern/\n# another comment\n")
(tmp_path / "file.py").write_text("match\n")
result = await grep.run(GrepArgs(pattern="match"))
assert result.match_count >= 1
@pytest.mark.asyncio
async def test_tracks_search_history(grep, tmp_path):
(tmp_path / "test.py").write_text("content\n")
await grep.run(GrepArgs(pattern="first"))
await grep.run(GrepArgs(pattern="second"))
await grep.run(GrepArgs(pattern="third"))
assert grep.state.search_history == ["first", "second", "third"]
@pytest.mark.asyncio
async def test_uses_effective_workdir(tmp_path):
config = GrepToolConfig(workdir=tmp_path)
grep_tool = Grep(config=config, state=GrepState())
(tmp_path / "test.py").write_text("match\n")
result = await grep_tool.run(GrepArgs(pattern="match", path="."))
assert result.match_count == 1
assert "test.py" in result.matches
@pytest.mark.skipif(not shutil.which("grep"), reason="GNU grep not available")
class TestGnuGrepBackend:
@pytest.mark.asyncio
async def test_finds_pattern_in_file(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n print('world')\n")
result = await grep_gnu_only.run(GrepArgs(pattern="hello"))
assert result.match_count == 1
assert "hello" in result.matches
assert "test.py" in result.matches
@pytest.mark.asyncio
async def test_finds_multiple_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("foo\nbar\nfoo\nbaz\nfoo\n")
result = await grep_gnu_only.run(GrepArgs(pattern="foo"))
assert result.match_count == 3
assert result.matches.count("foo") == 3
@pytest.mark.asyncio
async def test_returns_empty_on_no_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("def hello():\n pass\n")
result = await grep_gnu_only.run(GrepArgs(pattern="nonexistent"))
assert result.match_count == 0
assert result.matches == ""
@pytest.mark.asyncio
async def test_case_insensitive_for_lowercase_pattern(
self, grep_gnu_only, tmp_path
):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep_gnu_only.run(GrepArgs(pattern="hello"))
assert result.match_count == 3
@pytest.mark.asyncio
async def test_case_sensitive_for_mixed_case_pattern(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep_gnu_only.run(GrepArgs(pattern="Hello"))
assert result.match_count == 1
@pytest.mark.asyncio
async def test_respects_exclude_patterns(self, grep_gnu_only, tmp_path):
(tmp_path / "included.py").write_text("match\n")
node_modules = tmp_path / "node_modules"
node_modules.mkdir()
(node_modules / "excluded.js").write_text("match\n")
result = await grep_gnu_only.run(GrepArgs(pattern="match"))
assert "included.py" in result.matches
assert "excluded.js" not in result.matches
@pytest.mark.asyncio
async def test_searches_in_specific_path(self, grep_gnu_only, tmp_path):
subdir = tmp_path / "subdir"
subdir.mkdir()
(subdir / "test.py").write_text("match here\n")
(tmp_path / "other.py").write_text("match here too\n")
result = await grep_gnu_only.run(GrepArgs(pattern="match", path="subdir"))
assert result.match_count == 1
assert "other.py" not in result.matches
@pytest.mark.asyncio
async def test_respects_vibeignore_file(self, grep_gnu_only, tmp_path):
(tmp_path / ".vibeignore").write_text("custom_dir/\n*.tmp\n")
custom_dir = tmp_path / "custom_dir"
custom_dir.mkdir()
(custom_dir / "excluded.py").write_text("match\n")
(tmp_path / "excluded.tmp").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
result = await grep_gnu_only.run(GrepArgs(pattern="match"))
assert "included.py" in result.matches
assert "excluded.py" not in result.matches
assert "excluded.tmp" not in result.matches
@pytest.mark.asyncio
async def test_truncates_to_max_matches(self, grep_gnu_only, tmp_path):
(tmp_path / "test.py").write_text("\n".join(f"line {i}" for i in range(200)))
result = await grep_gnu_only.run(GrepArgs(pattern="line", max_matches=50))
assert result.match_count == 50
assert result.was_truncated
@pytest.mark.skipif(not shutil.which("rg"), reason="ripgrep not available")
class TestRipgrepBackend:
@pytest.mark.asyncio
async def test_smart_case_lowercase_pattern(self, grep, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep.run(GrepArgs(pattern="hello"))
assert result.match_count == 3
@pytest.mark.asyncio
async def test_smart_case_mixed_case_pattern(self, grep, tmp_path):
(tmp_path / "test.py").write_text("Hello\nHELLO\nhello\n")
result = await grep.run(GrepArgs(pattern="Hello"))
assert result.match_count == 1
@pytest.mark.asyncio
async def test_searches_ignored_files_when_use_default_ignore_false(
self, grep, tmp_path
):
(tmp_path / ".ignore").write_text("ignored_by_rg/\n")
ignored_dir = tmp_path / "ignored_by_rg"
ignored_dir.mkdir()
(ignored_dir / "file.py").write_text("match\n")
(tmp_path / "included.py").write_text("match\n")
result_with_ignore = await grep.run(GrepArgs(pattern="match"))
assert "included.py" in result_with_ignore.matches
assert "ignored_by_rg" not in result_with_ignore.matches
result_without_ignore = await grep.run(
GrepArgs(pattern="match", use_default_ignore=False)
)
assert "included.py" in result_without_ignore.matches
assert "ignored_by_rg/file.py" in result_without_ignore.matches

View file

@ -0,0 +1,138 @@
from __future__ import annotations
from pathlib import Path
import time
import pytest
from textual.widgets import Static
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import BashOutputMessage, ErrorMessage
from vibe.core.config import SessionLoggingConfig, VibeConfig
@pytest.fixture
def vibe_config(tmp_path: Path) -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), workdir=tmp_path
)
@pytest.fixture
def vibe_app(vibe_config: VibeConfig) -> VibeApp:
return VibeApp(config=vibe_config)
async def _wait_for_bash_output_message(
vibe_app: VibeApp, pilot, timeout: float = 1.0
) -> BashOutputMessage:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if message := next(iter(vibe_app.query(BashOutputMessage)), None):
return message
await pilot.pause(0.05)
raise TimeoutError(f"BashOutputMessage did not appear within {timeout}s")
def assert_no_command_error(vibe_app: VibeApp) -> None:
errors = list(vibe_app.query(ErrorMessage))
if not errors:
return
disallowed = {
"Command failed",
"Command timed out",
"No command provided after '!'",
}
offending = [
getattr(err, "_error", "")
for err in errors
if getattr(err, "_error", "")
and any(phrase in getattr(err, "_error", "") for phrase in disallowed)
]
assert not offending, f"Unexpected command errors: {offending}"
@pytest.mark.asyncio
async def test_ui_reports_no_output(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!true"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
output_widget = message.query_one(".bash-output", Static)
assert str(output_widget.render()) == "(no output)"
assert_no_command_error(vibe_app)
@pytest.mark.asyncio
async def test_ui_shows_success_in_case_of_zero_code(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!true"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
icon = message.query_one(".bash-exit-success", Static)
assert str(icon.render()) == ""
assert not list(message.query(".bash-exit-failure"))
@pytest.mark.asyncio
async def test_ui_shows_failure_in_case_of_non_zero_code(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!bash -lc 'exit 7'"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
icon = message.query_one(".bash-exit-failure", Static)
assert str(icon.render()) == ""
code = message.query_one(".bash-exit-code", Static)
assert "7" in str(code.render())
assert not list(message.query(".bash-exit-success"))
@pytest.mark.asyncio
async def test_ui_handles_non_utf8_output(vibe_app: VibeApp) -> None:
"""Assert the UI accepts decoding a non-UTF8 sequence like `printf '\xf0\x9f\x98'`.
Whereas `printf '\xf0\x9f\x98\x8b'` prints a smiley face (😋) and would work even without those changes.
"""
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!printf '\\xff\\xfe'"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
output_widget = message.query_one(".bash-output", Static)
# accept both possible encodings, as some shells emit escaped bytes as literal strings
assert str(output_widget.render()) in {"<EFBFBD><EFBFBD>", "\xff\xfe", r"\xff\xfe"}
assert_no_command_error(vibe_app)
@pytest.mark.asyncio
async def test_ui_handles_utf8_output(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!echo hello"
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
output_widget = message.query_one(".bash-output", Static)
assert str(output_widget.render()) == "hello\n"
assert_no_command_error(vibe_app)
@pytest.mark.asyncio
async def test_ui_handles_non_utf8_stderr(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!bash -lc \"printf '\\\\xff\\\\xfe' 1>&2\""
await pilot.press("enter")
message = await _wait_for_bash_output_message(vibe_app, pilot)
output_widget = message.query_one(".bash-output", Static)
assert str(output_widget.render()) == "<EFBFBD><EFBFBD>"
assert_no_command_error(vibe_app)