v2.14.1 (#756)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Stanislas <stanislas.lange@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
3f8487f761
commit
702d0f412e
51 changed files with 2446 additions and 502 deletions
|
|
@ -3,10 +3,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from acp.schema import AgentMessageChunk, AvailableCommandsUpdate, TextContentBlock
|
||||
from acp.schema import (
|
||||
AgentMessageChunk,
|
||||
AllowedOutcome,
|
||||
AvailableCommandsUpdate,
|
||||
DeniedOutcome,
|
||||
RequestPermissionResponse,
|
||||
TextContentBlock,
|
||||
ToolCallProgress,
|
||||
ToolCallStart,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from tests.acp.conftest import _create_acp_agent
|
||||
|
|
@ -14,8 +25,20 @@ from tests.skills.conftest import create_skill
|
|||
from tests.stubs.fake_backend import FakeBackend
|
||||
from tests.stubs.fake_client import FakeClient
|
||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||
from vibe.acp.teleport import TELEPORT_PUSH_OPTION_ID
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.config import SessionLoggingConfig
|
||||
from vibe.core.teleport.errors import ServiceTeleportError
|
||||
from vibe.core.teleport.teleport import TeleportService
|
||||
from vibe.core.teleport.types import (
|
||||
TeleportCheckingGitEvent,
|
||||
TeleportCompleteEvent,
|
||||
TeleportPushingEvent,
|
||||
TeleportPushRequiredEvent,
|
||||
TeleportPushResponseEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
)
|
||||
from vibe.core.types import LLMMessage, Role
|
||||
|
||||
|
||||
def _get_client(agent: VibeAcpAgentLoop) -> FakeClient:
|
||||
|
|
@ -32,14 +55,37 @@ def _get_message_texts(agent: VibeAcpAgentLoop) -> list[str]:
|
|||
]
|
||||
|
||||
|
||||
def _get_tool_updates(
|
||||
agent: VibeAcpAgentLoop,
|
||||
) -> list[ToolCallStart | ToolCallProgress]:
|
||||
return [
|
||||
u.update
|
||||
for u in _get_client(agent)._session_updates
|
||||
if isinstance(u.update, (ToolCallStart, ToolCallProgress))
|
||||
]
|
||||
|
||||
|
||||
def _set_teleport_service(agent_loop: AgentLoop, service: object) -> None:
|
||||
agent_loop._teleport_service = cast(TeleportService, service)
|
||||
|
||||
|
||||
async def _new_session_and_clear(agent: VibeAcpAgentLoop) -> str:
|
||||
"""Create a new session, drain the startup updates, return session_id."""
|
||||
resp = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
await asyncio.sleep(0) # let background tasks (available_commands) complete
|
||||
await _wait_for_available_commands(agent)
|
||||
_get_client(agent)._session_updates.clear()
|
||||
return resp.session_id
|
||||
|
||||
|
||||
async def _wait_for_available_commands(agent: VibeAcpAgentLoop) -> None:
|
||||
for _ in range(50):
|
||||
updates = _get_client(agent)._session_updates
|
||||
if any(isinstance(u.update, AvailableCommandsUpdate) for u in updates):
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise TimeoutError("available commands update was not sent")
|
||||
|
||||
|
||||
async def _prompt(agent: VibeAcpAgentLoop, session_id: str, text: str):
|
||||
return await agent.prompt(
|
||||
prompt=[TextContentBlock(type="text", text=text)], session_id=session_id
|
||||
|
|
@ -51,6 +97,7 @@ def _make_patched_agent_loop(
|
|||
*,
|
||||
skill_paths: list[Path] | None = None,
|
||||
session_logging: SessionLoggingConfig | None = None,
|
||||
vibe_code_enabled: bool | None = None,
|
||||
) -> type[AgentLoop]:
|
||||
"""Create a PatchedAgentLoop class that injects config overrides."""
|
||||
config_updates: dict = {}
|
||||
|
|
@ -58,6 +105,8 @@ def _make_patched_agent_loop(
|
|||
config_updates["skill_paths"] = skill_paths
|
||||
if session_logging is not None:
|
||||
config_updates["session_logging"] = session_logging
|
||||
if vibe_code_enabled is not None:
|
||||
config_updates["vibe_code_enabled"] = vibe_code_enabled
|
||||
|
||||
class PatchedAgentLoop(AgentLoop):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
|
|
@ -75,6 +124,13 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
|
|||
return _create_acp_agent()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def acp_agent_loop_vibe_code_disabled(backend: FakeBackend) -> VibeAcpAgentLoop:
|
||||
patched = _make_patched_agent_loop(backend, vibe_code_enabled=False)
|
||||
patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched).start()
|
||||
return _create_acp_agent()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "skills"
|
||||
|
|
@ -159,6 +215,264 @@ class TestHandleCompact:
|
|||
mock_compact.assert_called_once()
|
||||
|
||||
|
||||
class TestHandleTeleport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_commands_includes_teleport(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
await _wait_for_available_commands(acp_agent_loop)
|
||||
|
||||
available = [
|
||||
u
|
||||
for u in _get_client(acp_agent_loop)._session_updates
|
||||
if isinstance(u.update, AvailableCommandsUpdate)
|
||||
]
|
||||
cmd_names = [c.name for c in available[0].update.available_commands]
|
||||
|
||||
assert "teleport" in cmd_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_hidden_when_vibe_code_disabled(
|
||||
self, acp_agent_loop_vibe_code_disabled: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
agent = acp_agent_loop_vibe_code_disabled
|
||||
await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
await _wait_for_available_commands(agent)
|
||||
|
||||
available = [
|
||||
u
|
||||
for u in _get_client(agent)._session_updates
|
||||
if isinstance(u.update, AvailableCommandsUpdate)
|
||||
]
|
||||
cmd_names = [c.name for c in available[0].update.available_commands]
|
||||
|
||||
assert "teleport" not in cmd_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_without_history_replies_with_no_history(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_id = await _new_session_and_clear(acp_agent_loop)
|
||||
|
||||
response = await _prompt(acp_agent_loop, session_id, "/teleport")
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
assert response.field_meta == {
|
||||
"tool_name": "teleport",
|
||||
"teleport": {"status": "no_history"},
|
||||
}
|
||||
assert _get_message_texts(acp_agent_loop) == [
|
||||
"No conversation history to teleport."
|
||||
]
|
||||
assert _get_tool_updates(acp_agent_loop) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_sends_tool_updates_and_structured_url(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
class FakeTeleportService:
|
||||
prompts: list[str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.prompts = []
|
||||
|
||||
async def __aenter__(self) -> FakeTeleportService:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, prompt: str) -> AsyncGenerator[object, object]:
|
||||
self.prompts.append(prompt)
|
||||
yield TeleportCheckingGitEvent()
|
||||
yield TeleportStartingWorkflowEvent()
|
||||
yield TeleportCompleteEvent(url="https://chat.example.com/code/1/2")
|
||||
|
||||
session_id = await _new_session_and_clear(acp_agent_loop)
|
||||
session = acp_agent_loop.sessions[session_id]
|
||||
session.agent_loop.messages.append(
|
||||
LLMMessage(role=Role.user, content="continue this task")
|
||||
)
|
||||
service = FakeTeleportService()
|
||||
_set_teleport_service(session.agent_loop, service)
|
||||
|
||||
response = await _prompt(acp_agent_loop, session_id, "/teleport ignored")
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
assert response.field_meta == {
|
||||
"tool_name": "teleport",
|
||||
"teleport": {
|
||||
"status": "completed",
|
||||
"url": "https://chat.example.com/code/1/2",
|
||||
},
|
||||
}
|
||||
assert service.prompts == ["continue this task (continue)"]
|
||||
assert _get_message_texts(acp_agent_loop) == []
|
||||
|
||||
tool_updates = _get_tool_updates(acp_agent_loop)
|
||||
assert isinstance(tool_updates[0], ToolCallStart)
|
||||
assert tool_updates[0].title == "Teleporting session to Vibe Code Web..."
|
||||
assert tool_updates[-1].status == "completed"
|
||||
assert tool_updates[-1].title == "Teleported to Vibe Code Web"
|
||||
assert [update.field_meta for update in tool_updates] == [
|
||||
{"tool_name": "teleport", "teleport": {"status": "starting"}},
|
||||
{"tool_name": "teleport", "teleport": {"status": "preparing_workspace"}},
|
||||
{"tool_name": "teleport", "teleport": {"status": "starting_workflow"}},
|
||||
{
|
||||
"tool_name": "teleport",
|
||||
"teleport": {
|
||||
"status": "completed",
|
||||
"url": "https://chat.example.com/code/1/2",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_push_required_requests_permission(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
class FakeTeleportService:
|
||||
response: TeleportPushResponseEvent | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.response = None
|
||||
|
||||
async def __aenter__(self) -> FakeTeleportService:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, prompt: str) -> AsyncGenerator[object, object]:
|
||||
yield TeleportCheckingGitEvent()
|
||||
response = yield TeleportPushRequiredEvent(
|
||||
unpushed_count=2, branch_not_pushed=False
|
||||
)
|
||||
self.response = cast(TeleportPushResponseEvent, response)
|
||||
yield TeleportPushingEvent()
|
||||
yield TeleportCompleteEvent(url="https://chat.example.com/code/1/2")
|
||||
|
||||
session_id = await _new_session_and_clear(acp_agent_loop)
|
||||
session = acp_agent_loop.sessions[session_id]
|
||||
session.agent_loop.messages.append(
|
||||
LLMMessage(role=Role.user, content="ship it")
|
||||
)
|
||||
service = FakeTeleportService()
|
||||
_set_teleport_service(session.agent_loop, service)
|
||||
|
||||
client = _get_client(acp_agent_loop)
|
||||
client.request_permission = AsyncMock(
|
||||
return_value=RequestPermissionResponse(
|
||||
outcome=AllowedOutcome(
|
||||
outcome="selected", option_id=TELEPORT_PUSH_OPTION_ID
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
response = await _prompt(acp_agent_loop, session_id, "/teleport")
|
||||
|
||||
assert response.field_meta == {
|
||||
"tool_name": "teleport",
|
||||
"teleport": {
|
||||
"status": "completed",
|
||||
"url": "https://chat.example.com/code/1/2",
|
||||
},
|
||||
}
|
||||
assert service.response == TeleportPushResponseEvent(approved=True)
|
||||
request_permission = cast(AsyncMock, client.request_permission)
|
||||
request_permission.assert_awaited_once()
|
||||
await_args = request_permission.await_args
|
||||
assert await_args is not None
|
||||
kwargs = await_args.kwargs
|
||||
assert (
|
||||
kwargs["tool_call"].title
|
||||
== "You have 2 unpushed commits. Push to continue?"
|
||||
)
|
||||
assert kwargs["tool_call"].field_meta == {
|
||||
"tool_name": "teleport",
|
||||
"teleport": {
|
||||
"status": "push_required",
|
||||
"unpushedCount": 2,
|
||||
"branchNotPushed": False,
|
||||
},
|
||||
}
|
||||
assert [option.name for option in kwargs["options"]] == [
|
||||
"Push and continue",
|
||||
"Cancel",
|
||||
]
|
||||
assert [update.field_meta for update in _get_tool_updates(acp_agent_loop)] == [
|
||||
{"tool_name": "teleport", "teleport": {"status": "starting"}},
|
||||
{"tool_name": "teleport", "teleport": {"status": "preparing_workspace"}},
|
||||
{
|
||||
"tool_name": "teleport",
|
||||
"teleport": {
|
||||
"status": "push_required",
|
||||
"unpushedCount": 2,
|
||||
"branchNotPushed": False,
|
||||
},
|
||||
},
|
||||
{"tool_name": "teleport", "teleport": {"status": "syncing_remote"}},
|
||||
{
|
||||
"tool_name": "teleport",
|
||||
"teleport": {
|
||||
"status": "completed",
|
||||
"url": "https://chat.example.com/code/1/2",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_teleport_push_denied_marks_tool_call_failed(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
class FakeTeleportService:
|
||||
async def __aenter__(self) -> FakeTeleportService:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
async def execute(self, prompt: str) -> AsyncGenerator[object, object]:
|
||||
response = yield TeleportPushRequiredEvent()
|
||||
if (
|
||||
not isinstance(response, TeleportPushResponseEvent)
|
||||
or not response.approved
|
||||
):
|
||||
raise ServiceTeleportError(
|
||||
"Teleport cancelled: changes not pushed."
|
||||
)
|
||||
|
||||
session_id = await _new_session_and_clear(acp_agent_loop)
|
||||
session = acp_agent_loop.sessions[session_id]
|
||||
session.agent_loop.messages.append(
|
||||
LLMMessage(role=Role.user, content="ship it")
|
||||
)
|
||||
_set_teleport_service(session.agent_loop, FakeTeleportService())
|
||||
|
||||
client = _get_client(acp_agent_loop)
|
||||
client.request_permission = AsyncMock(
|
||||
return_value=RequestPermissionResponse(
|
||||
outcome=DeniedOutcome(outcome="cancelled")
|
||||
)
|
||||
)
|
||||
|
||||
response = await _prompt(acp_agent_loop, session_id, "/teleport")
|
||||
|
||||
assert response.field_meta == {
|
||||
"tool_name": "teleport",
|
||||
"teleport": {"status": "failed"},
|
||||
}
|
||||
failed = _get_tool_updates(acp_agent_loop)[-1]
|
||||
assert failed.status == "failed"
|
||||
assert failed.title == "Teleport failed"
|
||||
assert failed.raw_output == "Teleport cancelled: changes not pushed."
|
||||
assert failed.field_meta == {
|
||||
"tool_name": "teleport",
|
||||
"teleport": {"status": "failed"},
|
||||
}
|
||||
|
||||
|
||||
class TestHandleReload:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_calls_reload_with_initial_messages(
|
||||
|
|
@ -213,6 +527,19 @@ class TestCommandFallthrough:
|
|||
texts = _get_message_texts(acp_agent_loop)
|
||||
assert any("Hi" in t for t in texts)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ampersand_message_reaches_agent(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_id = await _new_session_and_clear(acp_agent_loop)
|
||||
response = await _prompt(acp_agent_loop, session_id, "&fix from here")
|
||||
|
||||
assert response.stop_reason == "end_turn"
|
||||
assert response.field_meta is None
|
||||
assert _get_tool_updates(acp_agent_loop) == []
|
||||
texts = _get_message_texts(acp_agent_loop)
|
||||
assert any("Hi" in t for t in texts)
|
||||
|
||||
|
||||
class TestAvailableCommandsWithSkills:
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -224,7 +551,7 @@ class TestAvailableCommandsWithSkills:
|
|||
await acp_agent_loop_with_skills.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_available_commands(acp_agent_loop_with_skills)
|
||||
|
||||
updates = _get_client(acp_agent_loop_with_skills)._session_updates
|
||||
available = [
|
||||
|
|
@ -247,7 +574,7 @@ class TestAvailableCommandsWithSkills:
|
|||
await acp_agent_loop_with_skills.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_available_commands(acp_agent_loop_with_skills)
|
||||
|
||||
updates = _get_client(acp_agent_loop_with_skills)._session_updates
|
||||
available = [
|
||||
|
|
|
|||
|
|
@ -43,9 +43,14 @@ def build_acp_agent_loop(*, provider: ProviderConfig | None = None) -> VibeAcpAg
|
|||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unauthenticated_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
|
||||
|
||||
class TestACPInitialize:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize(self) -> None:
|
||||
async def test_initialize(self, unauthenticated_env: None) -> None:
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
|
||||
|
|
@ -62,7 +67,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.1"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
@ -73,7 +78,9 @@ class TestACPInitialize:
|
|||
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_terminal_auth(self) -> None:
|
||||
async def test_initialize_with_terminal_auth(
|
||||
self, unauthenticated_env: None
|
||||
) -> None:
|
||||
"""Test initialize with terminal-auth capabilities to check it was included."""
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
|
||||
|
|
@ -94,7 +101,7 @@ class TestACPInitialize:
|
|||
),
|
||||
)
|
||||
assert response.agent_info == Implementation(
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0"
|
||||
name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.1"
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
|
|
@ -109,6 +116,8 @@ class TestACPInitialize:
|
|||
assert auth_method.id == "vibe-setup"
|
||||
assert auth_method.name == "Register your API Key"
|
||||
assert auth_method.description == "Register your API Key inside Mistral Vibe"
|
||||
assert auth_method.args is not None
|
||||
assert auth_method.args[-1:] == ["--setup"]
|
||||
assert auth_method.field_meta is not None
|
||||
assert "terminal-auth" in auth_method.field_meta
|
||||
terminal_auth_meta = auth_method.field_meta["terminal-auth"]
|
||||
|
|
@ -118,7 +127,9 @@ class TestACPInitialize:
|
|||
assert terminal_auth_meta["label"] == "Mistral Vibe Setup"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_with_delegated_browser_auth(self) -> None:
|
||||
async def test_initialize_with_delegated_browser_auth(
|
||||
self, unauthenticated_env: None
|
||||
) -> None:
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
client_capabilities = ClientCapabilities(
|
||||
field_meta={"browser-auth-delegated": True}
|
||||
|
|
@ -156,3 +167,43 @@ class TestACPInitialize:
|
|||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||
|
||||
assert response.auth_methods == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_omits_auth_methods_for_authenticated_jetbrains_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-api-key")
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
client_capabilities = ClientCapabilities(
|
||||
field_meta={"terminal-auth": True, "browser-auth-delegated": True}
|
||||
)
|
||||
client_info = Implementation(name="JetBrains.PyCharm", version="2026.1.2")
|
||||
|
||||
response = await acp_agent_loop.initialize(
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
client_capabilities=client_capabilities,
|
||||
client_info=client_info,
|
||||
)
|
||||
|
||||
assert response.auth_methods == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_keeps_auth_methods_for_authenticated_non_jetbrains_client(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-api-key")
|
||||
acp_agent_loop = build_acp_agent_loop()
|
||||
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
|
||||
client_info = Implementation(name="zed", version="0.999.0")
|
||||
|
||||
response = await acp_agent_loop.initialize(
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
client_capabilities=client_capabilities,
|
||||
client_info=client_info,
|
||||
)
|
||||
|
||||
assert response.auth_methods is not None
|
||||
assert {method.id for method in response.auth_methods} == {
|
||||
"browser-auth",
|
||||
"vibe-setup",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,14 +34,34 @@ def _get_fake_client(acp_agent_loop: VibeAcpAgentLoop) -> FakeClient:
|
|||
return acp_agent_loop.client
|
||||
|
||||
|
||||
async def _wait_for_available_commands(acp_agent_loop: VibeAcpAgentLoop) -> None:
|
||||
for _ in range(50):
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
if any(isinstance(u.update, AvailableCommandsUpdate) for u in updates):
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise TimeoutError("available commands update was not sent")
|
||||
|
||||
|
||||
class TestAvailableCommandsUpdate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_available_commands_are_delayed_until_after_new_session(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
assert not any(isinstance(u.update, AvailableCommandsUpdate) for u in updates)
|
||||
|
||||
await _wait_for_available_commands(acp_agent_loop)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_available_commands_sent_on_new_session(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_available_commands(acp_agent_loop)
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
available_commands_updates = [
|
||||
|
|
@ -62,7 +82,7 @@ class TestAvailableCommandsUpdate:
|
|||
) -> None:
|
||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
||||
|
||||
await asyncio.sleep(0)
|
||||
await _wait_for_available_commands(acp_agent_loop)
|
||||
|
||||
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||
available_commands_updates = [
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
|||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.middleware import TurnLimitMiddleware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -399,3 +400,131 @@ class TestACPSetConfigOptionThinking:
|
|||
)
|
||||
|
||||
assert response is None
|
||||
|
||||
|
||||
class TestACPSetConfigOptionMaxTurns:
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_turns_success(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_turns", value="100"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop._max_turns == 100
|
||||
turn_limits = [
|
||||
m
|
||||
for m in acp_session.agent_loop.middleware_pipeline.middlewares
|
||||
if isinstance(m, TurnLimitMiddleware)
|
||||
]
|
||||
assert len(turn_limits) == 1
|
||||
assert turn_limits[0].max_turns == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_turns_string_value(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_turns", value="50"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop._max_turns == 50
|
||||
turn_limits = [
|
||||
m
|
||||
for m in acp_session.agent_loop.middleware_pipeline.middlewares
|
||||
if isinstance(m, TurnLimitMiddleware)
|
||||
]
|
||||
assert len(turn_limits) == 1
|
||||
assert turn_limits[0].max_turns == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_turns_invalid_string_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_max_turns = acp_session.agent_loop._max_turns
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_turns", value="abc"
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop._max_turns == initial_max_turns
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_turns_bool_returns_none(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
initial_max_turns = acp_session.agent_loop._max_turns
|
||||
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_turns", value=True
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert acp_session.agent_loop._max_turns == initial_max_turns
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_option_max_turns_repeated_set(
|
||||
self, acp_agent_loop: VibeAcpAgentLoop
|
||||
) -> None:
|
||||
session_response = await acp_agent_loop.new_session(
|
||||
cwd=str(Path.cwd()), mcp_servers=[]
|
||||
)
|
||||
session_id = session_response.session_id
|
||||
acp_session = next(
|
||||
(s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
|
||||
)
|
||||
assert acp_session is not None
|
||||
|
||||
await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_turns", value="100"
|
||||
)
|
||||
response = await acp_agent_loop.set_config_option(
|
||||
session_id=session_id, config_id="max_turns", value="200"
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert acp_session.agent_loop._max_turns == 200
|
||||
turn_limits = [
|
||||
m
|
||||
for m in acp_session.agent_loop.middleware_pipeline.middlewares
|
||||
if isinstance(m, TurnLimitMiddleware)
|
||||
]
|
||||
assert len(turn_limits) == 1
|
||||
assert turn_limits[0].max_turns == 200
|
||||
|
|
|
|||
224
tests/cli/test_startup_update_prompt.py
Normal file
224
tests/cli/test_startup_update_prompt.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import build_test_vibe_config
|
||||
from vibe.cli.cli import _maybe_run_startup_update_prompt
|
||||
from vibe.cli.update_notifier import FileSystemUpdateCacheRepository, UpdateCache
|
||||
from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptResult
|
||||
|
||||
|
||||
class _BrokenRepository:
|
||||
async def get(self) -> UpdateCache | None:
|
||||
raise OSError("disk on fire")
|
||||
|
||||
async def set(self, update_cache: UpdateCache) -> None:
|
||||
raise OSError("disk on fire")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(tmp_path: Path) -> FileSystemUpdateCacheRepository:
|
||||
return FileSystemUpdateCacheRepository(base_path=tmp_path)
|
||||
|
||||
|
||||
def _write_pending_update(
|
||||
repository: FileSystemUpdateCacheRepository, version: str
|
||||
) -> None:
|
||||
asyncio.run(
|
||||
repository.set(
|
||||
UpdateCache(latest_version=version, stored_at_timestamp=int(time.time()))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_no_op_when_update_checks_are_disabled(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=False)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
mock_ask.assert_not_called()
|
||||
|
||||
|
||||
def test_no_op_when_no_pending_update_is_cached(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
|
||||
with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
mock_ask.assert_not_called()
|
||||
|
||||
|
||||
def test_prompt_is_shown_and_continue_returns_without_exiting(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||
) as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
mock_ask.assert_called_once()
|
||||
|
||||
|
||||
def test_quit_exits_zero(repository: FileSystemUpdateCacheRepository) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with (
|
||||
patch("vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.QUIT),
|
||||
pytest.raises(SystemExit) as excinfo,
|
||||
):
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
assert excinfo.value.code == 0
|
||||
|
||||
|
||||
def test_successful_update_exits_zero(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.UPDATED
|
||||
),
|
||||
pytest.raises(SystemExit) as excinfo,
|
||||
):
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
assert excinfo.value.code == 0
|
||||
|
||||
|
||||
def test_failed_update_exits_one(repository: FileSystemUpdateCacheRepository) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.cli.ask_update_prompt",
|
||||
return_value=UpdatePromptResult.UPDATE_FAILED,
|
||||
),
|
||||
pytest.raises(SystemExit) as excinfo,
|
||||
):
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
assert excinfo.value.code == 1
|
||||
|
||||
|
||||
def test_no_op_when_cache_read_raises_oserror() -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
repository = _BrokenRepository()
|
||||
|
||||
with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
mock_ask.assert_not_called()
|
||||
|
||||
|
||||
def test_continue_marks_version_as_dismissed_and_prevents_reprompt(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||
) as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
assert mock_ask.call_count == 1
|
||||
|
||||
|
||||
def test_continue_reprompts_when_a_newer_version_appears(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||
) as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
_write_pending_update(repository, "1000.0.0")
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
assert mock_ask.call_count == 2
|
||||
|
||||
|
||||
def test_successful_update_prints_restart_hint(
|
||||
repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.UPDATED
|
||||
),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "999.0.0" in out
|
||||
assert "Run" in out and "vibe" in out
|
||||
|
||||
|
||||
def test_failed_update_prints_error_message(
|
||||
repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.cli.ask_update_prompt",
|
||||
return_value=UpdatePromptResult.UPDATE_FAILED,
|
||||
),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
assert "could not be updated automatically" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_failed_update_does_not_dismiss_so_user_is_reprompted_on_next_launch(
|
||||
repository: FileSystemUpdateCacheRepository,
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True)
|
||||
_write_pending_update(repository, "999.0.0")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"vibe.cli.cli.ask_update_prompt",
|
||||
return_value=UpdatePromptResult.UPDATE_FAILED,
|
||||
),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
cache = asyncio.run(repository.get())
|
||||
assert cache is not None
|
||||
assert cache.dismissed_version is None
|
||||
|
||||
with patch(
|
||||
"vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
|
||||
) as mock_ask:
|
||||
_maybe_run_startup_update_prompt(config, repository)
|
||||
|
||||
mock_ask.assert_called_once()
|
||||
|
|
@ -54,7 +54,6 @@ def get_base_config() -> dict[str, Any]:
|
|||
"alias": "devstral-latest",
|
||||
}
|
||||
],
|
||||
"enable_auto_update": False,
|
||||
"enable_telemetry": False,
|
||||
}
|
||||
|
||||
|
|
|
|||
34
tests/core/test_build_attachment_counts.py
Normal file
34
tests/core/test_build_attachment_counts.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from vibe.core.telemetry.build_metadata import build_attachment_counts
|
||||
from vibe.core.telemetry.types import AttachmentKind
|
||||
from vibe.core.types import ImageAttachment, LLMMessage, Role
|
||||
|
||||
|
||||
def _msg(images: list[ImageAttachment] | None) -> LLMMessage:
|
||||
return LLMMessage(role=Role.user, content="hi", images=images)
|
||||
|
||||
|
||||
def _image() -> ImageAttachment:
|
||||
return ImageAttachment(
|
||||
path=Path("/tmp/x.png"), alias="x.png", mime_type="image/png"
|
||||
)
|
||||
|
||||
|
||||
def test_returns_empty_when_message_is_none() -> None:
|
||||
assert build_attachment_counts(None, supports_images=True) == {}
|
||||
|
||||
|
||||
def test_returns_empty_when_no_images() -> None:
|
||||
assert build_attachment_counts(_msg(None), supports_images=True) == {}
|
||||
|
||||
|
||||
def test_counts_images_when_model_supports_them() -> None:
|
||||
counts = build_attachment_counts(_msg([_image(), _image()]), supports_images=True)
|
||||
assert counts == {AttachmentKind.IMAGE: 2}
|
||||
|
||||
|
||||
def test_drops_images_when_model_does_not_support_them() -> None:
|
||||
assert build_attachment_counts(_msg([_image()]), supports_images=False) == {}
|
||||
|
|
@ -15,7 +15,11 @@ from vibe.core.telemetry.build_metadata import (
|
|||
build_request_metadata,
|
||||
)
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.telemetry.types import EntrypointMetadata, TelemetryRequestMetadata
|
||||
from vibe.core.telemetry.types import (
|
||||
AttachmentKind,
|
||||
EntrypointMetadata,
|
||||
TelemetryRequestMetadata,
|
||||
)
|
||||
from vibe.core.tools.base import BaseTool, ToolPermission
|
||||
from vibe.core.types import Backend
|
||||
from vibe.core.utils import get_user_agent
|
||||
|
|
@ -694,6 +698,43 @@ class TestTelemetryClient:
|
|||
assert properties["call_source"] == "vibe_code"
|
||||
assert properties["call_type"] == "main_call"
|
||||
assert properties["message_id"] is None
|
||||
assert properties["attachment_counts"] == {}
|
||||
|
||||
def test_send_request_sent_payload_with_attachments(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_request_sent(
|
||||
model="codestral",
|
||||
nb_context_chars=1234,
|
||||
nb_context_messages=5,
|
||||
nb_prompt_chars=42,
|
||||
call_type="main_call",
|
||||
attachment_counts={AttachmentKind.IMAGE: 2},
|
||||
)
|
||||
|
||||
assert len(telemetry_events) == 1
|
||||
properties = telemetry_events[0]["properties"]
|
||||
assert properties["attachment_counts"] == {"image": 2}
|
||||
|
||||
def test_send_request_sent_payload_drops_zero_counts(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_telemetry=True)
|
||||
client = TelemetryClient(config_getter=lambda: config)
|
||||
|
||||
client.send_request_sent(
|
||||
model="codestral",
|
||||
nb_context_chars=1234,
|
||||
nb_context_messages=5,
|
||||
nb_prompt_chars=42,
|
||||
call_type="main_call",
|
||||
attachment_counts={AttachmentKind.IMAGE: 0},
|
||||
)
|
||||
|
||||
assert telemetry_events[0]["properties"]["attachment_counts"] == {}
|
||||
|
||||
def test_send_user_rating_feedback_payload(
|
||||
self, telemetry_events: list[dict[str, Any]]
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ def write_e2e_config(vibe_home: Path, api_base: str) -> None:
|
|||
"\n".join([
|
||||
'active_model = "mock-model"',
|
||||
"enable_update_checks = false",
|
||||
"enable_auto_update = false",
|
||||
"disable_welcome_banner_animation = true",
|
||||
"",
|
||||
"[[providers]]",
|
||||
|
|
|
|||
0
tests/setup/__init__.py
Normal file
0
tests/setup/__init__.py
Normal file
127
tests/setup/test_update_prompt_dialog.py
Normal file
127
tests/setup/test_update_prompt_dialog.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.setup.update_prompt.update_prompt_dialog import (
|
||||
UpdateChoice,
|
||||
UpdatePromptApp,
|
||||
UpdatePromptResult,
|
||||
)
|
||||
|
||||
|
||||
async def _await_update_completion(app: UpdatePromptApp) -> None:
|
||||
while app._update_task is None:
|
||||
await asyncio.sleep(0.01)
|
||||
try:
|
||||
await app._update_task
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialog_returns_continue_on_continue_selection() -> None:
|
||||
app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("right")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.return_value is UpdatePromptResult.CONTINUE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialog_returns_updated_when_update_command_succeeds() -> None:
|
||||
app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
with patch(
|
||||
"vibe.setup.update_prompt.update_prompt_dialog.do_update",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
await _await_update_completion(app)
|
||||
await pilot.pause()
|
||||
|
||||
assert app.return_value is UpdatePromptResult.UPDATED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialog_returns_update_failed_when_update_command_fails() -> None:
|
||||
app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
with patch(
|
||||
"vibe.setup.update_prompt.update_prompt_dialog.do_update",
|
||||
new=AsyncMock(return_value=False),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
await _await_update_completion(app)
|
||||
await pilot.pause()
|
||||
|
||||
assert app.return_value is UpdatePromptResult.UPDATE_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialog_default_selection_is_update() -> None:
|
||||
app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app._dialog is not None
|
||||
assert app._dialog.selected is UpdateChoice.UPDATE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialog_returns_quit_on_ctrl_q() -> None:
|
||||
app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("ctrl+q")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.return_value is UpdatePromptResult.QUIT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dialog_returns_update_failed_when_do_update_raises() -> None:
|
||||
app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
with patch(
|
||||
"vibe.setup.update_prompt.update_prompt_dialog.do_update",
|
||||
new=AsyncMock(side_effect=OSError("boom")),
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
await _await_update_completion(app)
|
||||
await pilot.pause()
|
||||
|
||||
assert app.return_value is UpdatePromptResult.UPDATE_FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ctrl_q_during_update_cancels_subprocess_and_quits() -> None:
|
||||
app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
update_started = asyncio.Event()
|
||||
|
||||
async def slow_update() -> bool:
|
||||
update_started.set()
|
||||
await asyncio.sleep(60)
|
||||
return True
|
||||
|
||||
with patch(
|
||||
"vibe.setup.update_prompt.update_prompt_dialog.do_update", new=slow_update
|
||||
):
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("enter")
|
||||
await update_started.wait()
|
||||
await pilot.press("ctrl+q")
|
||||
await pilot.pause()
|
||||
|
||||
assert app.return_value is UpdatePromptResult.QUIT
|
||||
assert app._update_task is not None
|
||||
assert app._update_task.cancelled() or app._update_task.done()
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Generated with Rich https://www.textualize.io -->
|
||||
<style>
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Regular"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: local("FiraCode-Bold"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
||||
font-style: bold;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.terminal-matrix {
|
||||
font-family: Fira Code, monospace;
|
||||
font-size: 20px;
|
||||
line-height: 24.4px;
|
||||
font-variant-east-asian: full-width;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: arial;
|
||||
}
|
||||
|
||||
.terminal-r1 { fill: #c5c8c6 }
|
||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
||||
.terminal-r3 { fill: #68a0b3 }
|
||||
.terminal-r4 { fill: #ff8205 }
|
||||
.terminal-r5 { fill: #98729f;font-weight: bold }
|
||||
.terminal-r6 { fill: #98a84b }
|
||||
.terminal-r7 { fill: #98a84b;font-weight: bold }
|
||||
.terminal-r8 { fill: #868887 }
|
||||
</style>
|
||||
|
||||
<defs>
|
||||
<clipPath id="terminal-clip-terminal">
|
||||
<rect x="0" y="0" width="1463.0" height="877.4" />
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-0">
|
||||
<rect x="0" y="1.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-1">
|
||||
<rect x="0" y="25.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-2">
|
||||
<rect x="0" y="50.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-3">
|
||||
<rect x="0" y="74.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-4">
|
||||
<rect x="0" y="99.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-5">
|
||||
<rect x="0" y="123.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-6">
|
||||
<rect x="0" y="147.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-7">
|
||||
<rect x="0" y="172.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-8">
|
||||
<rect x="0" y="196.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-9">
|
||||
<rect x="0" y="221.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-10">
|
||||
<rect x="0" y="245.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-11">
|
||||
<rect x="0" y="269.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-12">
|
||||
<rect x="0" y="294.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-13">
|
||||
<rect x="0" y="318.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-14">
|
||||
<rect x="0" y="343.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-15">
|
||||
<rect x="0" y="367.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-16">
|
||||
<rect x="0" y="391.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-17">
|
||||
<rect x="0" y="416.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-18">
|
||||
<rect x="0" y="440.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-19">
|
||||
<rect x="0" y="465.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-20">
|
||||
<rect x="0" y="489.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-21">
|
||||
<rect x="0" y="513.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-22">
|
||||
<rect x="0" y="538.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-23">
|
||||
<rect x="0" y="562.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-24">
|
||||
<rect x="0" y="587.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-25">
|
||||
<rect x="0" y="611.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-26">
|
||||
<rect x="0" y="635.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-27">
|
||||
<rect x="0" y="660.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-28">
|
||||
<rect x="0" y="684.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-29">
|
||||
<rect x="0" y="709.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-30">
|
||||
<rect x="0" y="733.5" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-31">
|
||||
<rect x="0" y="757.9" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-32">
|
||||
<rect x="0" y="782.3" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-33">
|
||||
<rect x="0" y="806.7" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
<clipPath id="terminal-line-34">
|
||||
<rect x="0" y="831.1" width="1464" height="24.65"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppWithUpdate</text>
|
||||
<g transform="translate(26,22)">
|
||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
||||
</g>
|
||||
|
||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
||||
<rect fill="#4b4e55" x="719.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="611.5" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="635.9" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="939.4" y="635.9" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="660.3" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="951.6" y="660.3" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="684.7" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1378.6" y="684.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="709.1" width="707.6" height="24.65" shape-rendering="crispEdges"/>
|
||||
<g class="terminal-matrix">
|
||||
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
||||
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
||||
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
||||
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
||||
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
||||
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
||||
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
||||
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
||||
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
||||
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
||||
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
||||
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
||||
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
||||
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
||||
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
||||
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
||||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="244" clip-path="url(#terminal-line-20)">devstral-latest[off]</text><text class="terminal-r1" x="683.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
||||
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">┃</text><text class="terminal-r5" x="24.4" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">What's New</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r1" x="24.4" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">• Feature 1</text><text class="terminal-r6" x="719.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">▌</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r1" x="24.4" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">• Feature 2</text><text class="terminal-r6" x="719.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▌</text><text class="terminal-r7" x="744.2" y="654.4" textLength="195.2" clip-path="url(#terminal-line-26)">Update available</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
||||
</text><text class="terminal-r6" x="719.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">▌</text><text class="terminal-r1" x="744.2" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">1.0.4 => 1000.2.0</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
||||
</text><text class="terminal-r6" x="719.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">▌</text><text class="terminal-r1" x="744.2" y="703.2" textLength="634.4" clip-path="url(#terminal-line-28)">Please update mistral-vibe with your package manager</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
||||
</text><text class="terminal-r6" x="719.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">▌</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
||||
</text><text class="terminal-r1" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
||||
</text><text class="terminal-r2" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
||||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
||||
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -1,51 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||
FakeUpdateCacheRepository,
|
||||
)
|
||||
from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
|
||||
from vibe.cli.update_notifier import Update, UpdateCache
|
||||
|
||||
|
||||
class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
|
||||
def __init__(self):
|
||||
config = default_config()
|
||||
config.enable_update_checks = True
|
||||
update_notifier = FakeUpdateGateway(update=Update(latest_version="1000.2.0"))
|
||||
update_cache_repository = FakeUpdateCacheRepository(
|
||||
UpdateCache(
|
||||
latest_version="1.0.4",
|
||||
stored_at_timestamp=0,
|
||||
seen_whats_new_version=None,
|
||||
)
|
||||
)
|
||||
super().__init__(
|
||||
config=config,
|
||||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
current_version="1.0.4",
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_shows_release_update_notification(
|
||||
snap_compare: SnapCompare, tmp_path: Path
|
||||
) -> None:
|
||||
whats_new_file = tmp_path / "whats_new.md"
|
||||
whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
|
||||
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
|
||||
terminal_size=(120, 36),
|
||||
run_before=run_before,
|
||||
)
|
||||
42
tests/snapshots/test_ui_snapshot_update_prompt.py
Normal file
42
tests/snapshots/test_ui_snapshot_update_prompt.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from textual.pilot import Pilot
|
||||
|
||||
from tests.snapshots.snap_compare import SnapCompare
|
||||
from vibe.setup.update_prompt import update_prompt_dialog
|
||||
from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptApp
|
||||
|
||||
|
||||
class UpdatePromptSnapshotApp(UpdatePromptApp):
|
||||
CSS_PATH = str(
|
||||
Path(update_prompt_dialog.__file__).parent / "update_prompt_dialog.tcss"
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(current_version="1.0.0", latest_version="2.0.0")
|
||||
|
||||
|
||||
def test_snapshot_update_prompt_initial(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_update_prompt.py:UpdatePromptSnapshotApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
||||
|
||||
def test_snapshot_update_prompt_continue_selected(snap_compare: SnapCompare) -> None:
|
||||
async def run_before(pilot: Pilot) -> None:
|
||||
await pilot.pause(0.2)
|
||||
await pilot.press("right")
|
||||
await pilot.pause(0.1)
|
||||
|
||||
assert snap_compare(
|
||||
"test_ui_snapshot_update_prompt.py:UpdatePromptSnapshotApp",
|
||||
terminal_size=(80, 20),
|
||||
run_before=run_before,
|
||||
)
|
||||
|
|
@ -7,7 +7,6 @@ import time
|
|||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from textual.app import Notification
|
||||
|
||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||
|
|
@ -50,21 +49,6 @@ def build_update_test_app(
|
|||
return _build
|
||||
|
||||
|
||||
async def _wait_for_notification(
|
||||
app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
|
||||
) -> Notification:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
|
||||
while loop.time() < deadline:
|
||||
notifications = list(app._notifications)
|
||||
if notifications:
|
||||
return notifications[-1]
|
||||
await pilot.pause(interval)
|
||||
|
||||
pytest.fail("Notification not displayed")
|
||||
|
||||
|
||||
async def _assert_no_notifications(
|
||||
app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
|
||||
) -> None:
|
||||
|
|
@ -85,25 +69,25 @@ def vibe_config_with_update_checks_enabled() -> VibeConfig:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_update_notification(
|
||||
async def test_ui_writes_pending_update_to_cache_without_notifying(
|
||||
build_update_test_app: Callable[..., VibeApp],
|
||||
) -> None:
|
||||
notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
|
||||
app = build_update_test_app(update_notifier=notifier)
|
||||
repository = FakeUpdateCacheRepository()
|
||||
app = build_update_test_app(
|
||||
update_notifier=notifier, update_cache_repository=repository
|
||||
)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
notification = await _wait_for_notification(app, pilot, timeout=0.3)
|
||||
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||
|
||||
assert notification.severity == "information"
|
||||
assert notification.title == "Update available"
|
||||
assert (
|
||||
notification.message
|
||||
== "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
|
||||
)
|
||||
assert notifier.fetch_update_calls == 1
|
||||
assert repository.update_cache is not None
|
||||
assert repository.update_cache.latest_version == "0.2.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_does_not_display_update_notification_when_not_available(
|
||||
async def test_ui_does_not_notify_when_no_update_is_available(
|
||||
build_update_test_app: Callable[..., VibeApp],
|
||||
) -> None:
|
||||
notifier = FakeUpdateGateway(update=None)
|
||||
|
|
@ -115,7 +99,7 @@ async def test_ui_does_not_display_update_notification_when_not_available(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_warning_toast_when_check_fails(
|
||||
async def test_ui_does_not_notify_when_gateway_errors(
|
||||
build_update_test_app: Callable[..., VibeApp],
|
||||
) -> None:
|
||||
notifier = FakeUpdateGateway(
|
||||
|
|
@ -124,13 +108,7 @@ async def test_ui_displays_warning_toast_when_check_fails(
|
|||
app = build_update_test_app(update_notifier=notifier)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.3)
|
||||
notifications = list(app._notifications)
|
||||
|
||||
assert notifications
|
||||
warning = notifications[-1]
|
||||
assert warning.severity == "warning"
|
||||
assert "forbidden" in warning.message.lower()
|
||||
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -167,11 +145,11 @@ async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_alrea
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_does_show_toast_when_cache_entry_is_too_old(
|
||||
async def test_ui_refetches_and_updates_cache_when_cache_entry_is_too_old(
|
||||
build_update_test_app: Callable[..., VibeApp],
|
||||
) -> None:
|
||||
timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60
|
||||
notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
|
||||
notifier = FakeUpdateGateway(update=Update(latest_version="0.3.0"))
|
||||
update_cache = UpdateCache(
|
||||
latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago
|
||||
)
|
||||
|
|
@ -181,18 +159,11 @@ async def test_ui_does_show_toast_when_cache_entry_is_too_old(
|
|||
)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.3)
|
||||
notifications = list(app._notifications)
|
||||
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||
|
||||
assert notifications
|
||||
notification = notifications[-1]
|
||||
assert notification.severity == "information"
|
||||
assert notification.title == "Update available"
|
||||
assert (
|
||||
notification.message
|
||||
== "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
|
||||
)
|
||||
assert notifier.fetch_update_calls == 1
|
||||
assert update_cache_repository.update_cache is not None
|
||||
assert update_cache_repository.update_cache.latest_version == "0.3.0"
|
||||
|
||||
|
||||
async def _wait_for_whats_new_message(
|
||||
|
|
@ -339,51 +310,3 @@ async def test_ui_does_not_display_whats_new_when_file_does_not_exist(
|
|||
|
||||
assert repository.update_cache is not None
|
||||
assert repository.update_cache.seen_whats_new_version == "1.0.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_success_notification_when_auto_update_succeeds(
|
||||
build_update_test_app: Callable[..., VibeApp],
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True)
|
||||
notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
|
||||
|
||||
with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]):
|
||||
app = build_update_test_app(update_notifier=notifier, config=config)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.3)
|
||||
notifications = list(app._notifications)
|
||||
|
||||
assert notifications, "No notifications displayed"
|
||||
notification = notifications[-1]
|
||||
assert notification.severity == "information"
|
||||
assert notification.title == "Update successful"
|
||||
assert (
|
||||
notification.message
|
||||
== "0.1.0 => 0.2.0\nVibe was updated successfully. Please restart to use the new version."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ui_displays_update_notification_when_auto_update_fails(
|
||||
build_update_test_app: Callable[..., VibeApp],
|
||||
) -> None:
|
||||
config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True)
|
||||
notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
|
||||
|
||||
with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["false"]):
|
||||
app = build_update_test_app(update_notifier=notifier, config=config)
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.3)
|
||||
notifications = list(app._notifications)
|
||||
|
||||
assert notifications
|
||||
notification = notifications[-1]
|
||||
assert notification.severity == "information"
|
||||
assert notification.title == "Update available"
|
||||
assert (
|
||||
notification.message
|
||||
== "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ from vibe.cli.update_notifier import (
|
|||
UpdateGatewayCause,
|
||||
UpdateGatewayError,
|
||||
)
|
||||
from vibe.cli.update_notifier.update import UpdateError, get_update_if_available
|
||||
from vibe.cli.update_notifier.update import (
|
||||
UpdateError,
|
||||
get_pending_update_from_cache,
|
||||
get_update_if_available,
|
||||
mark_update_as_dismissed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -300,3 +305,136 @@ async def test_updates_cache_timestamp_with_current_version_when_gateway_errors(
|
|||
assert update_cache_repository.update_cache is not None
|
||||
assert update_cache_repository.update_cache.latest_version == "1.0.0"
|
||||
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_update_from_cache_returns_none_when_cache_is_empty() -> None:
|
||||
repository = FakeUpdateCacheRepository()
|
||||
|
||||
assert await get_pending_update_from_cache(repository, "1.0.0") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_update_from_cache_returns_none_when_cached_version_equals_current() -> (
|
||||
None
|
||||
):
|
||||
repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(latest_version="1.0.0", stored_at_timestamp=0)
|
||||
)
|
||||
|
||||
assert await get_pending_update_from_cache(repository, "1.0.0") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_update_from_cache_returns_none_when_cached_version_is_older() -> (
|
||||
None
|
||||
):
|
||||
repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(latest_version="0.9.0", stored_at_timestamp=0)
|
||||
)
|
||||
|
||||
assert await get_pending_update_from_cache(repository, "1.0.0") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_update_from_cache_returns_version_when_newer_exists() -> (
|
||||
None
|
||||
):
|
||||
repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(latest_version="1.2.3", stored_at_timestamp=0)
|
||||
)
|
||||
|
||||
assert await get_pending_update_from_cache(repository, "1.0.0") == "1.2.3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_update_from_cache_returns_none_when_current_is_invalid() -> (
|
||||
None
|
||||
):
|
||||
repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(latest_version="1.2.3", stored_at_timestamp=0)
|
||||
)
|
||||
|
||||
assert await get_pending_update_from_cache(repository, "not-a-version") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_update_from_cache_returns_none_when_dismissed() -> None:
|
||||
repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(
|
||||
latest_version="1.2.3", stored_at_timestamp=0, dismissed_version="1.2.3"
|
||||
)
|
||||
)
|
||||
|
||||
assert await get_pending_update_from_cache(repository, "1.0.0") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pending_update_from_cache_returns_version_when_dismissed_is_older() -> (
|
||||
None
|
||||
):
|
||||
repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(
|
||||
latest_version="1.3.0", stored_at_timestamp=0, dismissed_version="1.2.3"
|
||||
)
|
||||
)
|
||||
|
||||
assert await get_pending_update_from_cache(repository, "1.0.0") == "1.3.0"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_update_as_dismissed_persists_version_and_preserves_other_fields() -> (
|
||||
None
|
||||
):
|
||||
repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(
|
||||
latest_version="1.2.3",
|
||||
stored_at_timestamp=42,
|
||||
seen_whats_new_version="1.0.0",
|
||||
)
|
||||
)
|
||||
|
||||
await mark_update_as_dismissed(repository, "1.2.3")
|
||||
|
||||
assert repository.update_cache is not None
|
||||
assert repository.update_cache.latest_version == "1.2.3"
|
||||
assert repository.update_cache.stored_at_timestamp == 42
|
||||
assert repository.update_cache.seen_whats_new_version == "1.0.0"
|
||||
assert repository.update_cache.dismissed_version == "1.2.3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_update_as_dismissed_is_a_noop_when_cache_is_empty() -> None:
|
||||
repository = FakeUpdateCacheRepository()
|
||||
|
||||
await mark_update_as_dismissed(repository, "1.2.3")
|
||||
|
||||
assert repository.update_cache is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writing_cache_preserves_seen_whats_new_and_dismissed_version(
|
||||
current_timestamp: int,
|
||||
) -> None:
|
||||
update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.2"))
|
||||
timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
|
||||
update_cache_repository = FakeUpdateCacheRepository(
|
||||
update_cache=UpdateCache(
|
||||
latest_version="1.0.1",
|
||||
stored_at_timestamp=timestamp_two_days_ago,
|
||||
seen_whats_new_version="1.0.0",
|
||||
dismissed_version="1.0.1",
|
||||
)
|
||||
)
|
||||
|
||||
await get_update_if_available(
|
||||
update_notifier,
|
||||
current_version="1.0.0",
|
||||
update_cache_repository=update_cache_repository,
|
||||
get_current_timestamp=lambda: current_timestamp,
|
||||
)
|
||||
|
||||
assert update_cache_repository.update_cache is not None
|
||||
assert update_cache_repository.update_cache.latest_version == "1.0.2"
|
||||
assert update_cache_repository.update_cache.seen_whats_new_version == "1.0.0"
|
||||
assert update_cache_repository.update_cache.dismissed_version == "1.0.1"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue