v2.14.0 (#743)
Co-authored-by: Alexis Tacnet <alexis@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com> Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com> Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
ad0d5c9520
commit
3f8487f761
197 changed files with 10819 additions and 2830 deletions
|
|
@ -12,6 +12,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
|
|||
WhoAmIPlanType,
|
||||
decide_plan_offer,
|
||||
plan_offer_cta,
|
||||
plan_title,
|
||||
resolve_api_key_for_plan,
|
||||
)
|
||||
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
|
||||
|
|
@ -263,3 +264,20 @@ def test_teleport_eligibility_depends_on_chat_plan_and_current_key(
|
|||
response: WhoAmIResponse, expected: bool
|
||||
) -> None:
|
||||
assert PlanInfo.from_response(response).is_teleport_eligible() is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected_title"),
|
||||
[
|
||||
(PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="FREE"), "Free"),
|
||||
(
|
||||
PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="Scale plan"),
|
||||
"[API] Scale plan",
|
||||
),
|
||||
],
|
||||
ids=["free-api-plan", "paid-api-plan"],
|
||||
)
|
||||
def test_plan_title_uses_current_api_plan_labels(
|
||||
payload: PlanInfo, expected_title: str
|
||||
) -> None:
|
||||
assert plan_title(payload) == expected_title
|
||||
|
|
|
|||
416
tests/cli/smoke_binary.py
Normal file
416
tests/cli/smoke_binary.py
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Smoke tests for the built vibe binary.
|
||||
|
||||
Usage: python tests/cli/smoke_binary.py <binary-dir>
|
||||
|
||||
Tests:
|
||||
1. --version exits successfully
|
||||
2. Normal interactive launch starts far enough to load the main Textual app
|
||||
3. --setup starts far enough to load bundled setup/Textual assets
|
||||
4. Programmatic mode without an API key fails with the expected config error
|
||||
5. Runtime data files are present in the bundle
|
||||
6. The relocated bundle can be launched from PATH
|
||||
7. (Linux) No ELF binaries require executable stack (GNU_STACK RWE)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import NoReturn
|
||||
|
||||
|
||||
def _fail(msg: str) -> NoReturn:
|
||||
print(f"FAIL: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _isolated_env(vibe_home: Path) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["VIBE_HOME"] = str(vibe_home)
|
||||
env["TERM"] = env.get("TERM") or "xterm-256color"
|
||||
env.pop("MISTRAL_API_KEY", None)
|
||||
env.pop("VIBE_MISTRAL_API_KEY", None)
|
||||
return env
|
||||
|
||||
|
||||
def test_version(binary: Path) -> None:
|
||||
result = subprocess.run(
|
||||
[str(binary), "--version"], capture_output=True, text=True, timeout=30
|
||||
)
|
||||
if result.returncode != 0:
|
||||
_fail(
|
||||
f"--version exited with code {result.returncode}\nstderr: {result.stderr}"
|
||||
)
|
||||
print(f"PASS: --version -> {result.stdout.strip()}")
|
||||
|
||||
|
||||
def test_interactive_launch_loads_bundled_assets(binary: Path) -> None:
|
||||
if platform.system() == "Windows":
|
||||
# Windows does not provide the Unix pty module used to drive Textual here.
|
||||
print("SKIP: interactive pty smoke test (Windows)")
|
||||
return
|
||||
|
||||
import pty
|
||||
import select
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
workdir = tmp_path / "workdir"
|
||||
workdir.mkdir()
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
env = _isolated_env(tmp_path / "home")
|
||||
env["MISTRAL_API_KEY"] = "smoke-test-mock-key"
|
||||
proc = subprocess.Popen(
|
||||
[str(binary), "--trust", "--workdir", str(workdir)],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
env=env,
|
||||
text=False,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
output = bytearray()
|
||||
deadline = time.monotonic() + 30
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
readable, _, _ = select.select([master_fd], [], [], 0.2)
|
||||
if not readable:
|
||||
continue
|
||||
|
||||
chunk = os.read(master_fd, 4096)
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
|
||||
decoded = output.decode("utf-8", errors="replace")
|
||||
if "Traceback" in decoded or "StylesheetError" in decoded:
|
||||
_fail(decoded)
|
||||
|
||||
# This pins the Textual app title used as the main-app smoke marker.
|
||||
if "\x1b]0;Vibe\x07" in decoded and len(output) > 4096:
|
||||
print("PASS: interactive launch loaded bundled UI assets")
|
||||
return
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
os.close(master_fd)
|
||||
|
||||
_fail("interactive launch did not render expected UI output before timeout")
|
||||
|
||||
|
||||
def test_setup_loads_bundled_assets(binary: Path) -> None:
|
||||
if platform.system() == "Windows":
|
||||
# Windows does not provide the Unix pty module used to drive Textual here.
|
||||
print("SKIP: --setup pty smoke test (Windows)")
|
||||
return
|
||||
|
||||
import pty
|
||||
import select
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
workdir = Path(tmp) / "workdir"
|
||||
workdir.mkdir()
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
proc = subprocess.Popen(
|
||||
[str(binary), "--setup", "--workdir", str(workdir)],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
env=_isolated_env(Path(tmp) / "home"),
|
||||
text=False,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
output = bytearray()
|
||||
deadline = time.monotonic() + 30
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
|
||||
readable, _, _ = select.select([master_fd], [], [], 0.2)
|
||||
if not readable:
|
||||
continue
|
||||
|
||||
chunk = os.read(master_fd, 4096)
|
||||
if not chunk:
|
||||
break
|
||||
output.extend(chunk)
|
||||
|
||||
if b"Traceback" in output or b"StylesheetError" in output:
|
||||
_fail(output.decode("utf-8", errors="replace"))
|
||||
|
||||
if b"Mistral" in output or b"API" in output or b"Welcome" in output:
|
||||
print("PASS: --setup loaded bundled UI assets")
|
||||
return
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
os.close(master_fd)
|
||||
|
||||
_fail("--setup did not render expected setup UI text before timeout")
|
||||
|
||||
|
||||
def test_programmatic_missing_api_key(binary: Path) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
result = subprocess.run(
|
||||
[str(binary), "-p", "hello"],
|
||||
capture_output=True,
|
||||
env=_isolated_env(Path(tmp) / ".vibe"),
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
_fail("programmatic mode without API key unexpectedly succeeded")
|
||||
|
||||
output = f"{result.stdout}\n{result.stderr}"
|
||||
if "Traceback" in output:
|
||||
_fail(f"programmatic mode raised a traceback:\n{output}")
|
||||
# This pins the user-facing guidance for the missing API key path.
|
||||
if "Set the environment variable" not in output:
|
||||
_fail(f"missing expected API key guidance:\n{output}")
|
||||
|
||||
print("PASS: programmatic mode reports missing API key")
|
||||
|
||||
|
||||
def test_bundled_runtime_files(binary_dir: Path) -> None:
|
||||
bundle_root = binary_dir / "_internal" / "vibe"
|
||||
source_root = Path(__file__).resolve().parents[2] / "vibe"
|
||||
|
||||
if not bundle_root.is_dir():
|
||||
_fail(f"bundled vibe package not found at {bundle_root}")
|
||||
if not source_root.is_dir():
|
||||
_fail(f"source vibe package not found at {source_root}")
|
||||
|
||||
required_exact = [
|
||||
"whats_new.md",
|
||||
"cli/textual_ui/app.tcss",
|
||||
"setup/onboarding/onboarding.tcss",
|
||||
"setup/trusted_folders/trust_folder_dialog.tcss",
|
||||
]
|
||||
|
||||
missing_exact = [
|
||||
relative
|
||||
for relative in required_exact
|
||||
if not (bundle_root / relative).is_file()
|
||||
]
|
||||
if missing_exact:
|
||||
lines = ["Missing required bundled runtime file(s):"]
|
||||
lines.extend(f" - vibe/{relative}" for relative in missing_exact)
|
||||
_fail("\n".join(lines))
|
||||
|
||||
mirrored_globs = [
|
||||
"**/*.tcss",
|
||||
"**/*.md",
|
||||
"core/tools/builtins/*.py",
|
||||
"core/skills/builtins/*.py",
|
||||
]
|
||||
|
||||
for pattern in mirrored_globs:
|
||||
source_files = {
|
||||
path.relative_to(source_root)
|
||||
for path in source_root.glob(pattern)
|
||||
if path.is_file()
|
||||
}
|
||||
bundled_files = {
|
||||
path.relative_to(bundle_root)
|
||||
for path in bundle_root.glob(pattern)
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
missing = sorted(source_files - bundled_files)
|
||||
if missing:
|
||||
lines = [f"Bundle is missing runtime file(s) for pattern {pattern}:"]
|
||||
lines.extend(f" - vibe/{path}" for path in missing)
|
||||
_fail("\n".join(lines))
|
||||
|
||||
print("PASS: bundled runtime files are present")
|
||||
|
||||
|
||||
def test_installed_bundle_launches(binary_dir: Path, binary_name: str) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
install_dir = tmp_path / "install" / "vibe"
|
||||
workdir = tmp_path / "workdir"
|
||||
vibe_home = tmp_path / "home"
|
||||
workdir.mkdir()
|
||||
|
||||
shutil.copytree(binary_dir, install_dir)
|
||||
installed_binary = install_dir / binary_name
|
||||
if not installed_binary.exists():
|
||||
_fail(f"installed binary not found at {installed_binary}")
|
||||
if platform.system() != "Windows":
|
||||
installed_binary.chmod(0o755)
|
||||
|
||||
env = _isolated_env(vibe_home)
|
||||
env["PATH"] = f"{install_dir}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
result = subprocess.run(
|
||||
[binary_name, "--version"],
|
||||
capture_output=True,
|
||||
cwd=workdir,
|
||||
env=env,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
_fail(
|
||||
"installed bundle --version exited with code "
|
||||
f"{result.returncode}\nstderr: {result.stderr}"
|
||||
)
|
||||
if "Traceback" in f"{result.stdout}\n{result.stderr}":
|
||||
_fail(f"installed bundle raised a traceback:\n{result.stdout}\n{result.stderr}")
|
||||
|
||||
print(f"PASS: installed bundle launches from PATH -> {result.stdout.strip()}")
|
||||
|
||||
|
||||
_PT_GNU_STACK = 0x6474E551
|
||||
_PF_X = 0x1
|
||||
|
||||
|
||||
def _has_executable_stack(filepath: Path) -> bool | None:
|
||||
try:
|
||||
with filepath.open("rb") as f:
|
||||
magic = f.read(4)
|
||||
if magic != b"\x7fELF":
|
||||
return None
|
||||
|
||||
ei_class = f.read(1)[0]
|
||||
ei_data = f.read(1)[0]
|
||||
|
||||
match ei_data:
|
||||
case 1:
|
||||
endian = "<"
|
||||
case 2:
|
||||
endian = ">"
|
||||
case _:
|
||||
return None
|
||||
|
||||
if ei_class == 2:
|
||||
f.seek(32)
|
||||
(e_phoff,) = struct.unpack(f"{endian}Q", f.read(8))
|
||||
f.seek(54)
|
||||
(e_phentsize,) = struct.unpack(f"{endian}H", f.read(2))
|
||||
(e_phnum,) = struct.unpack(f"{endian}H", f.read(2))
|
||||
|
||||
for i in range(e_phnum):
|
||||
f.seek(e_phoff + i * e_phentsize)
|
||||
(p_type,) = struct.unpack(f"{endian}I", f.read(4))
|
||||
(p_flags,) = struct.unpack(f"{endian}I", f.read(4))
|
||||
if p_type == _PT_GNU_STACK:
|
||||
return bool(p_flags & _PF_X)
|
||||
|
||||
elif ei_class == 1:
|
||||
f.seek(28)
|
||||
(e_phoff,) = struct.unpack(f"{endian}I", f.read(4))
|
||||
f.seek(42)
|
||||
(e_phentsize,) = struct.unpack(f"{endian}H", f.read(2))
|
||||
(e_phnum,) = struct.unpack(f"{endian}H", f.read(2))
|
||||
|
||||
for i in range(e_phnum):
|
||||
off = e_phoff + i * e_phentsize
|
||||
f.seek(off)
|
||||
(p_type,) = struct.unpack(f"{endian}I", f.read(4))
|
||||
f.seek(off + 24)
|
||||
(p_flags,) = struct.unpack(f"{endian}I", f.read(4))
|
||||
if p_type == _PT_GNU_STACK:
|
||||
return bool(p_flags & _PF_X)
|
||||
|
||||
return False
|
||||
except (OSError, struct.error):
|
||||
return None
|
||||
|
||||
|
||||
def test_no_executable_stack(binary_dir: Path, binary_name: str) -> None:
|
||||
if platform.system() != "Linux":
|
||||
print("SKIP: executable stack check (not Linux)")
|
||||
return
|
||||
|
||||
internal_dir = binary_dir / "_internal"
|
||||
if not internal_dir.exists():
|
||||
_fail(f"_internal directory not found at {internal_dir}")
|
||||
|
||||
violations: list[Path] = []
|
||||
checked = 0
|
||||
candidates = [binary_dir / binary_name, *internal_dir.rglob("*")]
|
||||
|
||||
for filepath in candidates:
|
||||
if not filepath.is_file():
|
||||
continue
|
||||
result = _has_executable_stack(filepath)
|
||||
if result is None:
|
||||
continue
|
||||
checked += 1
|
||||
if result:
|
||||
violations.append(filepath)
|
||||
|
||||
if violations:
|
||||
lines = [
|
||||
f"Found {len(violations)} ELF file(s) with executable stack "
|
||||
f"(GNU_STACK RWE) out of {checked} checked.",
|
||||
"",
|
||||
"These will fail on SELinux-enforcing or hardened kernels:",
|
||||
]
|
||||
for violation in violations:
|
||||
lines.append(f" - {violation.relative_to(binary_dir)}")
|
||||
lines.append("")
|
||||
lines.append("Fix: run 'patchelf --clear-execstack' on these files.")
|
||||
_fail("\n".join(lines))
|
||||
|
||||
print(f"PASS: no executable stack in {checked} ELF files")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Usage: {sys.argv[0]} <binary-dir>")
|
||||
sys.exit(1)
|
||||
|
||||
binary_dir = Path(sys.argv[1])
|
||||
binary_name = "vibe.exe" if platform.system() == "Windows" else "vibe"
|
||||
binary = binary_dir / binary_name
|
||||
|
||||
if not binary.exists():
|
||||
_fail(f"binary not found at {binary}")
|
||||
|
||||
if platform.system() != "Windows":
|
||||
binary.chmod(0o755)
|
||||
|
||||
print(f"Testing binary: {binary}\n")
|
||||
|
||||
test_version(binary)
|
||||
test_bundled_runtime_files(binary_dir)
|
||||
test_installed_bundle_launches(binary_dir, binary_name)
|
||||
test_no_executable_stack(binary_dir, binary_name)
|
||||
test_interactive_launch_loads_bundled_assets(binary)
|
||||
test_setup_loads_bundled_assets(binary)
|
||||
test_programmatic_missing_api_key(binary)
|
||||
|
||||
print("\nAll smoke tests passed!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
193
tests/cli/test_check_and_resolve_trusted_folder.py
Normal file
193
tests/cli/test_check_and_resolve_trusted_folder.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.cli import entrypoint as entrypoint_mod
|
||||
from vibe.core.trusted_folders import trusted_folders_manager
|
||||
from vibe.setup.trusted_folders.trust_folder_dialog import TrustDecision
|
||||
|
||||
|
||||
def _make_git_repo(path: Path) -> None:
|
||||
git_dir = path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _git_sub_with_agents(
|
||||
tmp_path: Path, *, agents_at_sub: bool = True
|
||||
) -> tuple[Path, Path]:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_make_git_repo(repo)
|
||||
sub = repo / "src"
|
||||
sub.mkdir()
|
||||
if agents_at_sub:
|
||||
(sub / "AGENTS.md").write_text("# Agents", encoding="utf-8")
|
||||
else:
|
||||
(repo / "AGENTS.md").write_text("# Agents", encoding="utf-8")
|
||||
return repo, sub
|
||||
|
||||
|
||||
def _patch_ask(
|
||||
monkeypatch: pytest.MonkeyPatch, *, decision: TrustDecision = TrustDecision.DECLINE
|
||||
) -> dict[str, object]:
|
||||
captured: dict[str, object] = {"called": False}
|
||||
|
||||
def fake_ask(
|
||||
cwd: Path,
|
||||
repo_root: Path | None,
|
||||
detected_files: list[str],
|
||||
repo_detected_files: list[str] | None = None,
|
||||
offer_repo_trust: bool = False,
|
||||
repo_explicitly_untrusted: bool = False,
|
||||
) -> TrustDecision:
|
||||
captured["called"] = True
|
||||
captured["cwd"] = cwd
|
||||
captured["repo_root"] = repo_root
|
||||
captured["detected_files"] = detected_files
|
||||
captured["repo_detected_files"] = repo_detected_files
|
||||
captured["offer_repo_trust"] = offer_repo_trust
|
||||
captured["repo_explicitly_untrusted"] = repo_explicitly_untrusted
|
||||
return decision
|
||||
|
||||
monkeypatch.setattr(entrypoint_mod, "ask_trust_folder", fake_ask)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def away_from_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path / "elsewhere"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setup",
|
||||
[
|
||||
pytest.param("repo_already_trusted", id="repo_already_trusted"),
|
||||
pytest.param("cwd_explicitly_untrusted", id="cwd_explicitly_untrusted"),
|
||||
],
|
||||
)
|
||||
def test_skips_trust_dialog(
|
||||
setup: Literal["repo_already_trusted", "cwd_explicitly_untrusted"],
|
||||
tmp_path: Path,
|
||||
away_from_home: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
match setup:
|
||||
case "repo_already_trusted":
|
||||
repo, cwd = _git_sub_with_agents(tmp_path)
|
||||
trusted_folders_manager.add_trusted(repo)
|
||||
case "cwd_explicitly_untrusted":
|
||||
cwd = tmp_path / "plain"
|
||||
cwd.mkdir()
|
||||
(cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8")
|
||||
trusted_folders_manager.add_untrusted(cwd)
|
||||
|
||||
captured = _patch_ask(monkeypatch)
|
||||
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
|
||||
assert captured["called"] is False
|
||||
|
||||
|
||||
def test_shows_trust_dialog_when_only_repo_context_files_exist(
|
||||
tmp_path: Path, away_from_home: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
repo, cwd = _git_sub_with_agents(tmp_path, agents_at_sub=False)
|
||||
captured = _patch_ask(monkeypatch)
|
||||
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
|
||||
|
||||
assert captured["called"] is True
|
||||
assert captured["detected_files"] == []
|
||||
assert captured["repo_detected_files"] == ["AGENTS.md"]
|
||||
assert captured["repo_root"] == repo.resolve()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"untrusted_at,offer_repo_trust,repo_explicitly_untrusted",
|
||||
[
|
||||
pytest.param(None, True, False, id="repo_undecided"),
|
||||
pytest.param("repo", False, True, id="repo_explicitly_untrusted"),
|
||||
pytest.param("parent", True, False, id="parent_untrusted_only"),
|
||||
],
|
||||
)
|
||||
def test_ask_trust_folder_args_in_git_repo(
|
||||
untrusted_at: str | None,
|
||||
offer_repo_trust: bool,
|
||||
repo_explicitly_untrusted: bool,
|
||||
tmp_path: Path,
|
||||
away_from_home: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
if untrusted_at == "parent":
|
||||
parent = tmp_path / "parent"
|
||||
parent.mkdir()
|
||||
repo = parent / "repo"
|
||||
repo.mkdir()
|
||||
_make_git_repo(repo)
|
||||
cwd = repo / "src"
|
||||
cwd.mkdir()
|
||||
(cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8")
|
||||
trusted_folders_manager.add_untrusted(parent)
|
||||
else:
|
||||
repo, cwd = _git_sub_with_agents(tmp_path)
|
||||
if untrusted_at == "repo":
|
||||
trusted_folders_manager.add_untrusted(repo)
|
||||
|
||||
captured = _patch_ask(monkeypatch)
|
||||
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
|
||||
|
||||
assert captured["called"] is True
|
||||
assert captured["cwd"] == cwd
|
||||
assert captured["repo_root"] == repo.resolve()
|
||||
assert captured["detected_files"] == ["AGENTS.md"]
|
||||
assert captured["repo_detected_files"] == []
|
||||
assert captured["offer_repo_trust"] is offer_repo_trust
|
||||
assert captured["repo_explicitly_untrusted"] is repo_explicitly_untrusted
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("decision", "sub_trusted", "repo_trusted", "sub_explicitly_untrusted"),
|
||||
[
|
||||
pytest.param(TrustDecision.TRUST_REPO, True, True, False, id="trust_repo"),
|
||||
pytest.param(TrustDecision.TRUST_CWD, True, None, False, id="trust_cwd"),
|
||||
pytest.param(TrustDecision.DECLINE, False, None, True, id="decline"),
|
||||
],
|
||||
)
|
||||
def test_applies_trust_decision(
|
||||
decision: TrustDecision,
|
||||
sub_trusted: bool | None,
|
||||
repo_trusted: bool | None,
|
||||
sub_explicitly_untrusted: bool,
|
||||
tmp_path: Path,
|
||||
away_from_home: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repo, sub = _git_sub_with_agents(tmp_path)
|
||||
_patch_ask(monkeypatch, decision=decision)
|
||||
entrypoint_mod.check_and_resolve_trusted_folder(sub)
|
||||
|
||||
assert trusted_folders_manager.is_trusted(sub) is sub_trusted
|
||||
assert trusted_folders_manager.is_trusted(repo) is repo_trusted
|
||||
assert (
|
||||
trusted_folders_manager.is_explicitly_untrusted(sub) is sub_explicitly_untrusted
|
||||
)
|
||||
if decision == TrustDecision.TRUST_CWD:
|
||||
assert trusted_folders_manager.is_trusted(repo / "other") is None
|
||||
|
||||
|
||||
def test_no_git_repo_offers_no_repo_trust_and_decline_untrusts_cwd(
|
||||
tmp_path: Path, away_from_home: None, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cwd = tmp_path / "plain"
|
||||
cwd.mkdir()
|
||||
(cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8")
|
||||
|
||||
captured = _patch_ask(monkeypatch, decision=TrustDecision.DECLINE)
|
||||
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
|
||||
|
||||
assert captured["called"] is True
|
||||
assert captured["repo_root"] is None
|
||||
assert captured["repo_detected_files"] == []
|
||||
assert captured["offer_repo_trust"] is False
|
||||
assert trusted_folders_manager.is_explicitly_untrusted(cwd) is True
|
||||
|
|
@ -18,6 +18,7 @@ from vibe.cli.clipboard import (
|
|||
_read_clipboard,
|
||||
copy_selection_to_clipboard,
|
||||
copy_text_to_clipboard,
|
||||
try_copy_text_to_clipboard,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -206,6 +207,32 @@ def test_copy_text_to_clipboard_shows_failure_when_clipboard_unavailable(
|
|||
)
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard._copy_to_clipboard")
|
||||
def test_try_copy_text_to_clipboard_returns_true_when_copy_succeeds(
|
||||
mock_copy_to_clipboard: MagicMock,
|
||||
) -> None:
|
||||
result = try_copy_text_to_clipboard("assistant text")
|
||||
|
||||
assert result is True
|
||||
mock_copy_to_clipboard.assert_called_once_with("assistant text")
|
||||
|
||||
|
||||
@patch("vibe.cli.clipboard._copy_to_clipboard")
|
||||
def test_try_copy_text_to_clipboard_returns_false_when_clipboard_unavailable(
|
||||
mock_copy_to_clipboard: MagicMock,
|
||||
) -> None:
|
||||
mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed")
|
||||
|
||||
result = try_copy_text_to_clipboard("assistant text")
|
||||
|
||||
assert result is False
|
||||
mock_copy_to_clipboard.assert_called_once_with("assistant text")
|
||||
|
||||
|
||||
def test_try_copy_text_to_clipboard_returns_false_for_empty_text() -> None:
|
||||
assert try_copy_text_to_clipboard("") is False
|
||||
|
||||
|
||||
def test_copy_text_to_clipboard_returns_none_for_empty_text(
|
||||
mock_app: MagicMock,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ class TestConnectorMenuOrdering:
|
|||
)
|
||||
|
||||
ordered = _sort_connector_names_for_menu(
|
||||
registry.get_connector_names(), registry
|
||||
registry.get_connector_names(), registry, set()
|
||||
)
|
||||
|
||||
assert ordered == ["alpha", "beta", "zeta"]
|
||||
|
|
@ -261,11 +261,54 @@ class TestConnectorMenuOrdering:
|
|||
)
|
||||
|
||||
ordered = _sort_connector_names_for_menu(
|
||||
registry.get_connector_names(), registry
|
||||
registry.get_connector_names(), registry, set()
|
||||
)
|
||||
|
||||
assert ordered == ["alpha", "Beta", "Zeta"]
|
||||
|
||||
def test_disabled_connectors_sink_to_bottom(self) -> None:
|
||||
registry = FakeConnectorRegistry(
|
||||
connectors={
|
||||
"alpha": [RemoteTool(name="lookup", description="Lookup")],
|
||||
"beta": [RemoteTool(name="search", description="Search")],
|
||||
"gamma": [],
|
||||
}
|
||||
)
|
||||
|
||||
# alpha is enabled+connected, beta is enabled+connected but disabled in
|
||||
# config, gamma is enabled+disconnected. Expected: enabled-and-connected
|
||||
# first, then enabled-but-disconnected, then disabled at the bottom.
|
||||
ordered = _sort_connector_names_for_menu(
|
||||
registry.get_connector_names(), registry, {"beta"}
|
||||
)
|
||||
|
||||
assert ordered == ["alpha", "gamma", "beta"]
|
||||
|
||||
def test_full_ordering_enabled_then_connected_then_alpha(self) -> None:
|
||||
registry = FakeConnectorRegistry(
|
||||
connectors={
|
||||
"d_disabled_connected": [RemoteTool(name="t", description="t")],
|
||||
"b_enabled_disconnected": [],
|
||||
"a_enabled_connected": [RemoteTool(name="t", description="t")],
|
||||
"c_enabled_connected": [RemoteTool(name="t", description="t")],
|
||||
"e_disabled_disconnected": [],
|
||||
}
|
||||
)
|
||||
|
||||
ordered = _sort_connector_names_for_menu(
|
||||
registry.get_connector_names(),
|
||||
registry,
|
||||
{"d_disabled_connected", "e_disabled_disconnected"},
|
||||
)
|
||||
|
||||
assert ordered == [
|
||||
"a_enabled_connected",
|
||||
"c_enabled_connected",
|
||||
"b_enabled_disconnected",
|
||||
"d_disabled_connected",
|
||||
"e_disabled_disconnected",
|
||||
]
|
||||
|
||||
|
||||
class TestHelpBarChanges:
|
||||
"""The help bar should show 'Enter Authenticate' for disconnected connectors."""
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async def test_ui_displays_messages_when_resuming_session(
|
|||
id="tool_call_1",
|
||||
index=0,
|
||||
function=FunctionCall(
|
||||
name="read_file", arguments='{"path": "test.txt"}'
|
||||
name="read", arguments='{"file_path": "test.txt"}'
|
||||
),
|
||||
)
|
||||
],
|
||||
|
|
@ -53,7 +53,7 @@ async def test_ui_displays_messages_when_resuming_session(
|
|||
tool_result_msg = LLMMessage(
|
||||
role=Role.tool,
|
||||
content="File content here",
|
||||
name="read_file",
|
||||
name="read",
|
||||
tool_call_id="tool_call_1",
|
||||
)
|
||||
|
||||
|
|
@ -78,12 +78,12 @@ async def test_ui_displays_messages_when_resuming_session(
|
|||
# Verify tool call message is displayed
|
||||
tool_call_messages = app.query(ToolCallMessage)
|
||||
assert len(tool_call_messages) == 1
|
||||
assert tool_call_messages[0]._tool_name == "read_file"
|
||||
assert tool_call_messages[0]._tool_name == "read"
|
||||
|
||||
# Verify tool result message is displayed
|
||||
tool_result_messages = app.query(ToolResultMessage)
|
||||
assert len(tool_result_messages) == 1
|
||||
assert tool_result_messages[0].tool_name == "read_file"
|
||||
assert tool_result_messages[0].tool_name == "read"
|
||||
assert tool_result_messages[0]._content == "File content here"
|
||||
|
||||
|
||||
|
|
|
|||
64
tests/cli/textual_ui/test_chat_input_keybindings.py
Normal file
64
tests/cli/textual_ui/test_chat_input_keybindings.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_backspace_deletes_character_like_backspace() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
await pilot.press("a", "b", "c")
|
||||
await pilot.pause(0.1)
|
||||
assert app.query_one(ChatInputContainer).value == "abc"
|
||||
|
||||
await pilot.press("shift+backspace")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.query_one(ChatInputContainer).value == "ab"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_delete_deletes_character_like_delete() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
await pilot.pause(0.1)
|
||||
|
||||
await pilot.press("a", "b", "c", "left")
|
||||
await pilot.pause(0.1)
|
||||
assert app.query_one(ChatInputContainer).value == "abc"
|
||||
|
||||
await pilot.press("shift+delete")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert app.query_one(ChatInputContainer).value == "ab"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_backspace_resets_mode_when_empty() -> None:
|
||||
app = build_test_vibe_app()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
|
||||
text_area = app.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.set_mode("!")
|
||||
await pilot.pause(0.1)
|
||||
assert text_area.input_mode == "!"
|
||||
|
||||
await pilot.press("shift+backspace")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert text_area.input_mode == ">"
|
||||
149
tests/cli/textual_ui/test_image_input_validation.py
Normal file
149
tests/cli/textual_ui/test_image_input_validation.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from vibe.cli.textual_ui.app import _ImageAttachmentRejection
|
||||
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
|
||||
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
|
||||
from vibe.core.config import ModelConfig, ProviderConfig
|
||||
from vibe.core.types import (
|
||||
MAX_IMAGE_BYTES,
|
||||
MAX_IMAGES_PER_MESSAGE,
|
||||
Backend,
|
||||
ImageAttachment,
|
||||
)
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
|
||||
|
||||
|
||||
def _vision_config(*, supports_images: bool = True):
|
||||
models = [
|
||||
ModelConfig(
|
||||
name="mistral-vibe-cli-latest",
|
||||
provider="mistral",
|
||||
alias="devstral-latest",
|
||||
supports_images=supports_images,
|
||||
)
|
||||
]
|
||||
providers = [
|
||||
ProviderConfig(
|
||||
name="mistral",
|
||||
api_base="https://api.mistral.ai/v1",
|
||||
api_key_env_var="MISTRAL_API_KEY",
|
||||
backend=Backend.MISTRAL,
|
||||
)
|
||||
]
|
||||
return build_test_vibe_config(
|
||||
active_model="devstral-latest", models=models, providers=providers
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_happy_path(tmp_path: Path) -> None:
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
|
||||
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], ImageAttachment)
|
||||
assert result[0].alias == "shot.png"
|
||||
assert result[0].mime_type == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_returns_empty_when_no_images(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "notes.md").write_text("hi")
|
||||
payload = build_path_prompt_payload("read @notes.md", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_rejects_too_many_images(tmp_path: Path) -> None:
|
||||
mentions = []
|
||||
for i in range(MAX_IMAGES_PER_MESSAGE + 1):
|
||||
name = f"img{i}.png"
|
||||
(tmp_path / name).write_bytes(PNG_BYTES)
|
||||
mentions.append(f"@{name}")
|
||||
payload = build_path_prompt_payload(" ".join(mentions), base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, _ImageAttachmentRejection)
|
||||
assert not result.no_vision
|
||||
assert "Too many image attachments" in result.message
|
||||
assert str(MAX_IMAGES_PER_MESSAGE) in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_rejects_non_vision_model(tmp_path: Path) -> None:
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
|
||||
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config(supports_images=False))
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, _ImageAttachmentRejection)
|
||||
assert result.no_vision
|
||||
assert "does not support images" in result.message
|
||||
assert "devstral-latest" in result.message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_image_attachments_rejects_oversize_image(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Patch the cap down so we don't need to write 10MB to disk.
|
||||
monkeypatch.setattr("vibe.cli.textual_ui.app.MAX_IMAGE_BYTES", 32)
|
||||
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES + b"\x00" * 64)
|
||||
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
|
||||
|
||||
app = build_test_vibe_app(config=_vision_config())
|
||||
result = await app._build_image_attachments(payload)
|
||||
|
||||
assert isinstance(result, _ImageAttachmentRejection)
|
||||
assert not result.no_vision
|
||||
assert "shot.png" in result.message
|
||||
assert "max" in result.message.lower()
|
||||
|
||||
|
||||
def test_max_image_bytes_default_is_10_mib() -> None:
|
||||
assert MAX_IMAGE_BYTES == 10 * 1024 * 1024
|
||||
|
||||
|
||||
def test_max_images_per_message_default_is_8() -> None:
|
||||
assert MAX_IMAGES_PER_MESSAGE == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_restores_input_when_image_validation_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
|
||||
app = build_test_vibe_app(config=_vision_config(supports_images=False))
|
||||
typed = f"look at @{tmp_path / 'shot.png'}"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInputContainer)
|
||||
chat_input.value = typed
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == typed
|
||||
errors = list(app.query(ErrorMessage))
|
||||
assert errors
|
||||
assert all(not e._show_border for e in errors)
|
||||
221
tests/cli/textual_ui/test_paste_path.py
Normal file
221
tests/cli/textual_ui/test_paste_path.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from textual import events
|
||||
|
||||
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.chat_input.paste_path import (
|
||||
maybe_prepend_at_for_image_path,
|
||||
rewrite_bare_image_paths_in_text,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
|
||||
|
||||
def test_bare_absolute_image_path_gets_at_prefix(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(str(img))
|
||||
|
||||
assert rewritten == f"@{img}"
|
||||
|
||||
|
||||
def test_quoted_image_path_with_spaces_is_unwrapped_and_quoted(tmp_path: Path) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
pasted = f"'{img}'"
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(pasted)
|
||||
|
||||
assert rewritten == f"@'{img}'"
|
||||
|
||||
|
||||
def test_backslash_escaped_image_path_is_unescaped_and_quoted(tmp_path: Path) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
pasted = str(img).replace(" ", "\\ ")
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(pasted)
|
||||
|
||||
assert rewritten == f"@'{img}'"
|
||||
|
||||
|
||||
def test_non_image_file_path_is_left_untouched(tmp_path: Path) -> None:
|
||||
txt = tmp_path / "readme.md"
|
||||
txt.write_text("hi")
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(str(txt))
|
||||
|
||||
assert rewritten == str(txt)
|
||||
|
||||
|
||||
def test_missing_image_path_is_left_untouched(tmp_path: Path) -> None:
|
||||
missing = tmp_path / "nope.png"
|
||||
|
||||
rewritten = maybe_prepend_at_for_image_path(str(missing))
|
||||
|
||||
assert rewritten == str(missing)
|
||||
|
||||
|
||||
def test_unresolvable_tilde_user_does_not_crash() -> None:
|
||||
# `~a` raises RuntimeError from Path.expanduser() when user `a` does not
|
||||
# exist; the rewrite hook must swallow it so every keystroke after `~`
|
||||
# does not crash the TUI.
|
||||
assert maybe_prepend_at_for_image_path("~a") == "~a"
|
||||
assert rewrite_bare_image_paths_in_text("hello ~a world") == "hello ~a world"
|
||||
|
||||
|
||||
def test_multiline_paste_is_left_untouched(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
pasted = f"{img}\nother line"
|
||||
|
||||
assert maybe_prepend_at_for_image_path(pasted) == pasted
|
||||
|
||||
|
||||
def test_relative_path_is_left_untouched(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "shot.png").write_bytes(b"\x89PNG")
|
||||
|
||||
assert maybe_prepend_at_for_image_path("shot.png") == "shot.png"
|
||||
|
||||
|
||||
def test_already_at_prefixed_path_is_left_untouched(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
pasted = f"@{img}"
|
||||
assert maybe_prepend_at_for_image_path(pasted) == pasted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paste_event_inserts_at_prefixed_path_into_chat_input(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.post_message(events.Paste(text=str(img)))
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == f"@{img}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paste_event_leaves_non_image_paths_untouched(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
txt = tmp_path / "notes.md"
|
||||
txt.write_text("hi")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.post_message(events.Paste(text=str(txt)))
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == str(txt)
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_handles_bare_path(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
rewritten = rewrite_bare_image_paths_in_text(f"look at {img} please")
|
||||
|
||||
assert rewritten == f"look at @{img} please"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_handles_quoted_path(tmp_path: Path) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
rewritten = rewrite_bare_image_paths_in_text(f"look at '{img}'")
|
||||
|
||||
assert rewritten == f"look at @'{img}'"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_is_idempotent(tmp_path: Path) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
once = rewrite_bare_image_paths_in_text(f"see {img}")
|
||||
twice = rewrite_bare_image_paths_in_text(once)
|
||||
|
||||
assert once == twice == f"see @{img}"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_skips_non_image(tmp_path: Path) -> None:
|
||||
txt = tmp_path / "notes.md"
|
||||
txt.write_text("hi")
|
||||
|
||||
rewritten = rewrite_bare_image_paths_in_text(f"see {txt}")
|
||||
|
||||
assert rewritten == f"see {txt}"
|
||||
|
||||
|
||||
def test_rewrite_bare_image_paths_fast_path_skips_stat_for_plain_text(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
calls = 0
|
||||
original = _Path.is_file
|
||||
|
||||
def _counting_is_file(self):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original(self)
|
||||
|
||||
monkeypatch.setattr(_Path, "is_file", _counting_is_file)
|
||||
|
||||
rewrite_bare_image_paths_in_text("hello world, nothing path-shaped here")
|
||||
rewrite_bare_image_paths_in_text("multi\nline\ntext\nwith no slash")
|
||||
|
||||
assert calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_change_hook_rewrites_quoted_image_path(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
img = tmp_path / "shot.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
# Simulate a non-bracketed-paste insertion (terminal that does
|
||||
# not emit Paste): set the text directly the way a bulk insert
|
||||
# would land it.
|
||||
text_area.text = f"'{img}'"
|
||||
await pilot.pause()
|
||||
|
||||
# No spaces in the path -> the scanner emits an unquoted `@<path>`.
|
||||
assert chat_input.value == f"@{img}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_change_hook_rewrites_quoted_image_path_with_spaces(
|
||||
vibe_app: VibeApp, tmp_path: Path
|
||||
) -> None:
|
||||
img = tmp_path / "has space.png"
|
||||
img.write_bytes(b"\x89PNG")
|
||||
|
||||
async with vibe_app.run_test() as pilot:
|
||||
chat_input = vibe_app.query_one(ChatInputContainer)
|
||||
text_area = chat_input.query_one(ChatTextArea)
|
||||
text_area.focus()
|
||||
text_area.text = f"'{img}'"
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == f"@'{img}'"
|
||||
18
tests/cli/textual_ui/test_read_widget.py
Normal file
18
tests/cli/textual_ui/test_read_widget.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import _strip_line_numbers
|
||||
|
||||
|
||||
def test_strips_numbered_prefixes() -> None:
|
||||
content = " 1→first\n 42→second\n 100→third"
|
||||
assert _strip_line_numbers(content) == "first\nsecond\nthird"
|
||||
|
||||
|
||||
def test_leaves_warning_lines_untouched() -> None:
|
||||
content = "<vibe_warning>Warning: the file exists but the contents are empty.</vibe_warning>"
|
||||
assert _strip_line_numbers(content) == content
|
||||
|
||||
|
||||
def test_preserves_arrows_inside_content() -> None:
|
||||
content = " 1→a → b → c"
|
||||
assert _strip_line_numbers(content) == "a → b → c"
|
||||
78
tests/cli/textual_ui/test_user_message_attachments.py
Normal file
78
tests/cli/textual_ui/test_user_message_attachments.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from vibe.cli.textual_ui.widgets.messages import UserMessage
|
||||
from vibe.cli.textual_ui.windowing.history import build_history_widgets
|
||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
||||
|
||||
|
||||
def _att(path: Path, alias: str) -> ImageAttachment:
|
||||
path.write_bytes(b"\x89PNG")
|
||||
return ImageAttachment(path=path, alias=alias, mime_type="image/png")
|
||||
|
||||
|
||||
def test_attachments_footer_singular(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
|
||||
assert "attached image:" in rendered
|
||||
assert '[link="file://' in rendered
|
||||
assert "]shot.png[/link]" in rendered
|
||||
|
||||
|
||||
def test_attachments_footer_plural(tmp_path: Path) -> None:
|
||||
a = _att(tmp_path / "a.png", "a.png")
|
||||
b = _att(tmp_path / "b.png", "b.png")
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([a, b])
|
||||
|
||||
assert "attached images:" in rendered
|
||||
assert rendered.count('[link="file://') == 2
|
||||
assert "a.png" in rendered
|
||||
assert "b.png" in rendered
|
||||
|
||||
|
||||
def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "weird [bracket].png")
|
||||
|
||||
rendered = UserMessage._format_attachments_footer([att])
|
||||
|
||||
# Rich's escape() turns "[" into "\[".
|
||||
assert "\\[bracket]" in rendered
|
||||
|
||||
|
||||
def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
msg = LLMMessage(role=Role.user, content="look at @shot.png", images=[att])
|
||||
|
||||
widgets = build_history_widgets(
|
||||
[msg],
|
||||
tool_call_map={},
|
||||
start_index=0,
|
||||
tools_collapsed=False,
|
||||
history_widget_indices=WeakKeyDictionary(),
|
||||
)
|
||||
|
||||
assert len(widgets) == 1
|
||||
user_widget = widgets[0]
|
||||
assert isinstance(user_widget, UserMessage)
|
||||
assert user_widget._images == [att]
|
||||
|
||||
|
||||
def test_resumed_user_message_with_images_only_still_mounts(tmp_path: Path) -> None:
|
||||
att = _att(tmp_path / "shot.png", "shot.png")
|
||||
msg = LLMMessage(role=Role.user, content="", images=[att])
|
||||
|
||||
widgets = build_history_widgets(
|
||||
[msg],
|
||||
tool_call_map={},
|
||||
start_index=0,
|
||||
tools_collapsed=False,
|
||||
history_widget_indices=WeakKeyDictionary(),
|
||||
)
|
||||
|
||||
assert len(widgets) == 1
|
||||
assert isinstance(widgets[0], UserMessage)
|
||||
Loading…
Add table
Add a link
Reference in a new issue