v2.7.4 (#579)
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai> Co-authored-by: Peter Evers <pevers90@gmail.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Pierre Rossinès <pierre.rossines@protonmail.com> Co-authored-by: Quentin <quentin.torroba@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@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: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
90763daf81
commit
e9a9217cc8
113 changed files with 7202 additions and 541 deletions
|
|
@ -78,3 +78,29 @@ class TestAgentManager:
|
|||
|
||||
assert agent.name == "default"
|
||||
assert agent.agent_type == AgentType.AGENT
|
||||
|
||||
def test_initial_agent_rejects_subagent(self) -> None:
|
||||
"""Test that creating AgentManager with a subagent as initial_agent raises."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
with pytest.raises(ValueError, match="cannot be used as the primary agent"):
|
||||
AgentManager(lambda: config, initial_agent="explore")
|
||||
|
||||
def test_initial_agent_accepts_subagent_when_allowed(self) -> None:
|
||||
"""Test that allow_subagent=True permits subagent as initial_agent."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
manager = AgentManager(
|
||||
lambda: config, initial_agent="explore", allow_subagent=True
|
||||
)
|
||||
assert manager.active_profile.name == "explore"
|
||||
|
||||
def test_initial_agent_accepts_agent_type(self) -> None:
|
||||
"""Test that creating AgentManager with an agent-type agent works."""
|
||||
config = build_test_vibe_config(
|
||||
include_project_context=False, include_prompt_detail=False
|
||||
)
|
||||
manager = AgentManager(lambda: config, initial_agent="plan")
|
||||
assert manager.active_profile.name == "plan"
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
from vibe.core.config import OtelExporterConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.config import OtelSpanExporterConfig, ProviderConfig, VibeConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
|
||||
class TestOtelExporterConfig:
|
||||
class TestOtelSpanExporterConfig:
|
||||
def test_derives_endpoint_from_mistral_provider(
|
||||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
|
@ -22,7 +22,7 @@ class TestOtelExporterConfig:
|
|||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
result = config.otel_span_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://customer.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-test"}
|
||||
|
|
@ -49,7 +49,7 @@ class TestOtelExporterConfig:
|
|||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
result = config.otel_span_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://eu.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-eu"}
|
||||
|
|
@ -67,7 +67,7 @@ class TestOtelExporterConfig:
|
|||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
result = config.otel_span_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-fallback"}
|
||||
|
|
@ -76,7 +76,7 @@ class TestOtelExporterConfig:
|
|||
self, vibe_config: VibeConfig, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "sk-default")
|
||||
result = vibe_config.otel_exporter_config
|
||||
result = vibe_config.otel_span_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://api.mistral.ai/telemetry/v1/traces"
|
||||
|
||||
|
|
@ -88,7 +88,7 @@ class TestOtelExporterConfig:
|
|||
) -> None:
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
with caplog.at_level("WARNING"):
|
||||
assert vibe_config.otel_exporter_config is None
|
||||
assert vibe_config.otel_span_exporter_config is None
|
||||
assert "OTEL tracing enabled but MISTRAL_API_KEY is not set" in caplog.text
|
||||
|
||||
def test_custom_api_key_env_var(
|
||||
|
|
@ -108,20 +108,33 @@ class TestOtelExporterConfig:
|
|||
]
|
||||
}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
result = config.otel_span_exporter_config
|
||||
assert result is not None
|
||||
assert result.endpoint == "https://onprem.corp.com/telemetry/v1/traces"
|
||||
assert result.headers == {"Authorization": "Bearer sk-custom"}
|
||||
|
||||
def test_explicit_otel_endpoint_bypasses_provider_derivation(
|
||||
def test_explicit_otel_endpoint_appends_default_traces_path(
|
||||
self, vibe_config: VibeConfig
|
||||
) -> None:
|
||||
config = vibe_config.model_copy(
|
||||
update={"otel_endpoint": "https://my-collector:4318/v1/traces"}
|
||||
update={"otel_endpoint": "https://my-collector:4318"}
|
||||
)
|
||||
result = config.otel_exporter_config
|
||||
result = config.otel_span_exporter_config
|
||||
assert result is not None
|
||||
assert result == OtelExporterConfig(
|
||||
assert result == OtelSpanExporterConfig(
|
||||
endpoint="https://my-collector:4318/v1/traces"
|
||||
)
|
||||
assert result.headers is None
|
||||
|
||||
def test_explicit_otel_endpoint_preserves_path_prefix(
|
||||
self, vibe_config: VibeConfig
|
||||
) -> None:
|
||||
config = vibe_config.model_copy(
|
||||
update={"otel_endpoint": "https://my-collector:4318/api/public/otel"}
|
||||
)
|
||||
result = config.otel_span_exporter_config
|
||||
assert result is not None
|
||||
assert result == OtelSpanExporterConfig(
|
||||
endpoint="https://my-collector:4318/api/public/otel/v1/traces"
|
||||
)
|
||||
assert result.headers is None
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ from unittest.mock import MagicMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from vibe.core.logger import StructuredLogFormatter, apply_logging_config
|
||||
from vibe.core.logger import (
|
||||
StructuredLogFormatter,
|
||||
apply_logging_config,
|
||||
decode_log_message,
|
||||
encode_log_message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -278,3 +283,63 @@ class TestApplyLoggingConfig:
|
|||
handler = test_logger.handlers[-1]
|
||||
assert isinstance(handler, RotatingFileHandler)
|
||||
assert handler.maxBytes == 5242880
|
||||
|
||||
|
||||
class TestDecodeLogMessage:
|
||||
def test_plain_message_unchanged(self) -> None:
|
||||
assert decode_log_message("Hello world") == "Hello world"
|
||||
|
||||
def test_decodes_escaped_newline(self) -> None:
|
||||
assert decode_log_message("hello\\nworld") == "hello\nworld"
|
||||
|
||||
def test_decodes_escaped_backslash(self) -> None:
|
||||
assert decode_log_message("C:\\\\path") == "C:\\path"
|
||||
|
||||
def test_decodes_escaped_backslash_before_n(self) -> None:
|
||||
# This is the bug case: C:\new encoded as C:\\new must decode back to C:\new
|
||||
assert decode_log_message("C:\\\\new") == "C:\\new"
|
||||
|
||||
def test_roundtrip_with_newlines(self) -> None:
|
||||
original = "line one\nline two\nline three"
|
||||
encoded = encode_log_message(original)
|
||||
assert decode_log_message(encoded) == original
|
||||
|
||||
def test_roundtrip_with_backslashes(self) -> None:
|
||||
original = "C:\\Users\\test\\file.txt"
|
||||
encoded = encode_log_message(original)
|
||||
assert decode_log_message(encoded) == original
|
||||
|
||||
def test_roundtrip_with_backslash_n(self) -> None:
|
||||
original = "C:\\new folder\\notes.txt"
|
||||
encoded = encode_log_message(original)
|
||||
assert decode_log_message(encoded) == original
|
||||
|
||||
def test_roundtrip_mixed(self) -> None:
|
||||
original = "path: C:\\new\nand a newline"
|
||||
encoded = encode_log_message(original)
|
||||
assert decode_log_message(encoded) == original
|
||||
|
||||
def test_exception_encoding_escapes_backslashes(self) -> None:
|
||||
formatter = StructuredLogFormatter()
|
||||
try:
|
||||
raise ValueError("error in C:\\new\\path")
|
||||
except ValueError:
|
||||
import sys
|
||||
|
||||
exc_info = sys.exc_info()
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="test",
|
||||
level=logging.ERROR,
|
||||
pathname="test.py",
|
||||
lineno=1,
|
||||
msg="fail",
|
||||
args=(),
|
||||
exc_info=exc_info,
|
||||
)
|
||||
|
||||
output = formatter.format(record)
|
||||
|
||||
assert "\n" not in output
|
||||
# The backslashes in the exception should be escaped
|
||||
assert "C:\\\\new\\\\path" in output
|
||||
|
|
|
|||
430
tests/core/test_log_reader.py
Normal file
430
tests/core/test_log_reader.py
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import FrozenInstanceError
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.log_reader import LogEntry, LogReader
|
||||
|
||||
|
||||
def _wait_for(condition: Callable[[], bool], timeout: float = 3.0) -> bool:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if condition():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
return False
|
||||
|
||||
|
||||
class TestLogEntry:
|
||||
def test_log_entry_is_frozen(self) -> None:
|
||||
entry = LogEntry(
|
||||
timestamp=datetime.now(),
|
||||
ppid=1,
|
||||
pid=123,
|
||||
level="INFO",
|
||||
message="test",
|
||||
raw_line="raw",
|
||||
line_number=1,
|
||||
)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
entry.message = "modified" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestLogReaderParsing:
|
||||
@pytest.fixture
|
||||
def log_file(self, tmp_path: Path) -> Path:
|
||||
return tmp_path / "test.log"
|
||||
|
||||
def test_parses_valid_info_log(self, log_file: Path) -> None:
|
||||
log_file.write_text(
|
||||
"2026-02-08T10:30:45.123000+00:00 1234 5678 INFO Test message\n"
|
||||
)
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert len(result.entries) == 1
|
||||
assert result.entries[0].level == "INFO"
|
||||
assert result.entries[0].message == "Test message"
|
||||
|
||||
def test_parses_valid_error_log(self, log_file: Path) -> None:
|
||||
log_file.write_text(
|
||||
"2026-02-08T10:30:45.123000+00:00 1234 5678 ERROR Error message\n"
|
||||
)
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert result.entries[0].level == "ERROR"
|
||||
|
||||
def test_parses_log_with_ppid_pid(self, log_file: Path) -> None:
|
||||
log_file.write_text("2026-02-08T10:30:45.123000+00:00 1111 2222 DEBUG msg\n")
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert result.entries[0].ppid == 1111
|
||||
assert result.entries[0].pid == 2222
|
||||
|
||||
def test_skips_invalid_log_lines(self, log_file: Path) -> None:
|
||||
log_file.write_text(
|
||||
"invalid line\n2026-02-08T10:30:45.123000+00:00 1 2 INFO valid\n"
|
||||
)
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert len(result.entries) == 1
|
||||
assert result.entries[0].message == "valid"
|
||||
|
||||
def test_skips_multiline_continuations(self, log_file: Path) -> None:
|
||||
log_file.write_text(
|
||||
"2026-02-08T10:30:45.123000+00:00 1 2 INFO msg\n continuation\n"
|
||||
)
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert len(result.entries) == 1
|
||||
|
||||
def test_handles_empty_file(self, log_file: Path) -> None:
|
||||
log_file.write_text("")
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert result.entries == []
|
||||
|
||||
def test_handles_missing_file(self, tmp_path: Path) -> None:
|
||||
missing = tmp_path / "nonexistent.log"
|
||||
reader = LogReader(log_file=missing)
|
||||
result = reader.get_logs()
|
||||
assert result.entries == []
|
||||
|
||||
def test_skips_line_with_invalid_timestamp(self, log_file: Path) -> None:
|
||||
log_file.write_text("not-a-timestamp 1 2 INFO message\n")
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert result.entries == []
|
||||
|
||||
def test_skips_line_with_invalid_level(self, log_file: Path) -> None:
|
||||
log_file.write_text("2026-02-08T10:30:45.123000+00:00 1 2 UNKNOWN message\n")
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert result.entries == []
|
||||
|
||||
def test_skips_line_missing_pid(self, log_file: Path) -> None:
|
||||
log_file.write_text("2026-02-08T10:30:45.123000+00:00 1 INFO message\n")
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert result.entries == []
|
||||
|
||||
def test_skips_empty_lines(self, log_file: Path) -> None:
|
||||
log_file.write_text("\n\n2026-02-08T10:30:45.123000+00:00 1 2 INFO valid\n\n")
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert len(result.entries) == 1
|
||||
|
||||
def test_handles_extremely_long_lines(self, log_file: Path) -> None:
|
||||
long_message = "x" * (128 * 1024) # 128KB message
|
||||
log_file.write_text(
|
||||
f"2026-02-08T10:30:45.123000+00:00 1 2 INFO {long_message}\n"
|
||||
f"2026-02-08T10:30:46.123000+00:00 1 2 INFO short\n"
|
||||
)
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
assert len(result.entries) == 2
|
||||
assert result.entries[0].message == "short"
|
||||
assert result.entries[1].message == long_message
|
||||
|
||||
|
||||
class TestLogReaderMassiveLogs:
|
||||
@pytest.fixture
|
||||
def log_file(self, tmp_path: Path) -> Path:
|
||||
return tmp_path / "test.log"
|
||||
|
||||
def test_limit_prevents_reading_entire_large_file(self, log_file: Path) -> None:
|
||||
num_lines = 10_000
|
||||
lines = [
|
||||
f"2026-02-08T10:30:{i % 60:02d}.{i:06d}+00:00 1 2 INFO Message {i}\n"
|
||||
for i in range(num_lines)
|
||||
]
|
||||
log_file.write_text("".join(lines))
|
||||
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs(limit=10)
|
||||
|
||||
assert len(result.entries) == 10
|
||||
assert result.has_more is True
|
||||
|
||||
def test_handles_file_with_mostly_invalid_lines(self, log_file: Path) -> None:
|
||||
invalid_lines = ["garbage line\n"] * 1000
|
||||
valid_line = "2026-02-08T10:30:45.123000+00:00 1 2 INFO valid entry\n"
|
||||
log_file.write_text("".join(invalid_lines) + valid_line)
|
||||
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs(limit=100)
|
||||
|
||||
assert len(result.entries) == 1
|
||||
assert result.entries[0].message == "valid entry"
|
||||
|
||||
def test_handles_binary_garbage_in_file(self, log_file: Path) -> None:
|
||||
binary_garbage = bytes(range(256))
|
||||
valid_line = b"2026-02-08T10:30:45.123000+00:00 1 2 INFO valid after binary\n"
|
||||
log_file.write_bytes(binary_garbage + b"\n" + valid_line)
|
||||
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
|
||||
assert len(result.entries) == 1
|
||||
assert result.entries[0].message == "valid after binary"
|
||||
|
||||
def test_handles_null_bytes_in_lines(self, log_file: Path) -> None:
|
||||
line_with_nulls = (
|
||||
"2026-02-08T10:30:45.123000+00:00 1 2 INFO msg\x00with\x00nulls\n"
|
||||
)
|
||||
valid_line = "2026-02-08T10:30:46.123000+00:00 1 2 INFO clean message\n"
|
||||
log_file.write_text(line_with_nulls + valid_line)
|
||||
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
|
||||
assert len(result.entries) == 2
|
||||
assert result.entries[0].message == "clean message"
|
||||
assert result.entries[1].message == "msg\x00with\x00nulls"
|
||||
|
||||
def test_handles_massive_single_line_without_newline(self, log_file: Path) -> None:
|
||||
massive_line = "x" * (1024 * 1024)
|
||||
log_file.write_text(massive_line)
|
||||
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs()
|
||||
|
||||
assert result.entries == []
|
||||
|
||||
|
||||
class TestLogReaderPagination:
|
||||
@pytest.fixture
|
||||
def log_file_with_entries(self, tmp_path: Path) -> Path:
|
||||
log_file = tmp_path / "test.log"
|
||||
lines = [
|
||||
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
|
||||
for i in range(10)
|
||||
]
|
||||
log_file.write_text("".join(lines))
|
||||
return log_file
|
||||
|
||||
def test_returns_logs_newest_first(self, log_file_with_entries: Path) -> None:
|
||||
reader = LogReader(log_file=log_file_with_entries)
|
||||
result = reader.get_logs()
|
||||
assert "Message 9" in result.entries[0].message
|
||||
|
||||
def test_limit_restricts_results(self, log_file_with_entries: Path) -> None:
|
||||
reader = LogReader(log_file=log_file_with_entries)
|
||||
result = reader.get_logs(limit=3)
|
||||
assert len(result.entries) == 3
|
||||
|
||||
def test_has_more_false_when_exhausted(self, log_file_with_entries: Path) -> None:
|
||||
reader = LogReader(log_file=log_file_with_entries)
|
||||
result = reader.get_logs(limit=100)
|
||||
assert result.has_more is False
|
||||
|
||||
def test_cursor_continues_from_previous_position(
|
||||
self, log_file_with_entries: Path
|
||||
) -> None:
|
||||
reader = LogReader(log_file=log_file_with_entries)
|
||||
page1 = reader.get_logs(limit=3)
|
||||
assert len(page1.entries) == 3
|
||||
assert page1.has_more is True
|
||||
assert page1.cursor is not None
|
||||
|
||||
page2 = reader.get_logs(limit=3, offset=page1.cursor)
|
||||
assert len(page2.entries) == 3
|
||||
assert page2.has_more is True
|
||||
assert page2.entries[0].message != page1.entries[-1].message
|
||||
|
||||
|
||||
class TestLogReaderWatcher:
|
||||
@pytest.fixture
|
||||
def log_reader(self, tmp_path: Path) -> Generator[LogReader, None, None]:
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("")
|
||||
reader = LogReader(log_file=log_file)
|
||||
yield reader
|
||||
reader.shutdown()
|
||||
|
||||
def test_start_watching_sets_is_watching(self, log_reader: LogReader) -> None:
|
||||
log_reader.start_watching()
|
||||
assert log_reader.is_watching is True
|
||||
|
||||
def test_stop_watching_clears_is_watching(self, log_reader: LogReader) -> None:
|
||||
log_reader.start_watching()
|
||||
log_reader.stop_watching()
|
||||
assert log_reader.is_watching is False
|
||||
|
||||
def test_consumer_receives_new_entries(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("")
|
||||
received: list[LogEntry] = []
|
||||
reader = LogReader(log_file=log_file, consumer=received.append)
|
||||
try:
|
||||
reader.start_watching()
|
||||
|
||||
with log_file.open("a") as f:
|
||||
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO New entry\n")
|
||||
|
||||
assert _wait_for(lambda: len(received) >= 1)
|
||||
assert received[0].message == "New entry"
|
||||
finally:
|
||||
reader.shutdown()
|
||||
|
||||
def test_toggle_watching_on_off(self, log_reader: LogReader) -> None:
|
||||
log_reader.start_watching()
|
||||
assert log_reader.is_watching is True
|
||||
log_reader.stop_watching()
|
||||
assert log_reader.is_watching is False
|
||||
log_reader.start_watching()
|
||||
assert log_reader.is_watching is True
|
||||
|
||||
def test_set_consumer_updates_callback(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("")
|
||||
received: list[LogEntry] = []
|
||||
reader = LogReader(log_file=log_file)
|
||||
try:
|
||||
reader.set_consumer(received.append)
|
||||
reader.start_watching()
|
||||
|
||||
with log_file.open("a") as f:
|
||||
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO Entry\n")
|
||||
|
||||
assert _wait_for(lambda: len(received) >= 1)
|
||||
finally:
|
||||
reader.shutdown()
|
||||
|
||||
def test_consumer_can_call_get_logs_without_deadlock(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("")
|
||||
reader = LogReader(log_file=log_file, poll_interval=0.05)
|
||||
callback_completed = threading.Event()
|
||||
callback_errors: list[Exception] = []
|
||||
|
||||
def consumer(_: LogEntry) -> None:
|
||||
try:
|
||||
reader.get_logs(limit=1)
|
||||
except Exception as exc:
|
||||
callback_errors.append(exc)
|
||||
finally:
|
||||
callback_completed.set()
|
||||
|
||||
reader.set_consumer(consumer)
|
||||
try:
|
||||
reader.start_watching()
|
||||
with log_file.open("a") as f:
|
||||
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO Entry\n")
|
||||
|
||||
assert callback_completed.wait(timeout=1.0)
|
||||
assert callback_errors == []
|
||||
finally:
|
||||
reader.shutdown()
|
||||
|
||||
|
||||
class TestLogReaderCleanup:
|
||||
def test_shutdown_stops_watching(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("")
|
||||
reader = LogReader(log_file=log_file)
|
||||
reader.start_watching()
|
||||
assert reader.is_watching is True
|
||||
reader.shutdown()
|
||||
assert reader.is_watching is False
|
||||
|
||||
|
||||
class TestLogReaderLineNumbers:
|
||||
def test_line_numbers_relative_from_end(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
lines = [
|
||||
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
|
||||
for i in range(5)
|
||||
]
|
||||
log_file.write_text("".join(lines))
|
||||
|
||||
reader = LogReader(log_file=log_file)
|
||||
result = reader.get_logs(limit=5)
|
||||
|
||||
assert result.entries[0].line_number == 1 # Newest
|
||||
assert result.entries[0].message == "Message 4"
|
||||
assert result.entries[1].line_number == 2 # Second newest
|
||||
assert result.entries[1].message == "Message 3"
|
||||
|
||||
def test_live_entries_have_zero_line_number(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
log_file.write_text("")
|
||||
received: list[LogEntry] = []
|
||||
reader = LogReader(log_file=log_file, consumer=received.append)
|
||||
try:
|
||||
reader.start_watching()
|
||||
|
||||
with log_file.open("a") as f:
|
||||
f.write("2026-02-08T10:30:45.123000+00:00 1 2 INFO Live entry\n")
|
||||
|
||||
assert _wait_for(lambda: len(received) >= 1)
|
||||
assert received[0].line_number == 0
|
||||
assert received[0].message == "Live entry"
|
||||
finally:
|
||||
reader.shutdown()
|
||||
|
||||
|
||||
class TestLogReaderCursorDrift:
|
||||
def test_cursor_stable_with_new_logs(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
lines = [
|
||||
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
|
||||
for i in range(10)
|
||||
]
|
||||
log_file.write_text("".join(lines))
|
||||
|
||||
received: list[LogEntry] = []
|
||||
reader = LogReader(log_file=log_file, consumer=received.append)
|
||||
|
||||
try:
|
||||
page1 = reader.get_logs(limit=3)
|
||||
assert page1.entries[0].message == "Message 9"
|
||||
assert page1.cursor == 3
|
||||
|
||||
reader.start_watching()
|
||||
with log_file.open("a") as f:
|
||||
f.write("2026-02-08T10:30:50.000000+00:00 1 2 INFO New message 1\n")
|
||||
f.write("2026-02-08T10:30:51.000000+00:00 1 2 INFO New message 2\n")
|
||||
|
||||
assert _wait_for(lambda: len(received) >= 2)
|
||||
|
||||
page2 = reader.get_logs(limit=3, offset=page1.cursor)
|
||||
assert page2.entries[0].message == "Message 6"
|
||||
assert page2.entries[1].message == "Message 5"
|
||||
assert page2.entries[2].message == "Message 4"
|
||||
|
||||
finally:
|
||||
reader.shutdown()
|
||||
|
||||
def test_stop_watching_resets_counter(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "test.log"
|
||||
lines = [
|
||||
f"2026-02-08T10:30:{i:02d}.000000+00:00 1 2 INFO Message {i}\n"
|
||||
for i in range(5)
|
||||
]
|
||||
log_file.write_text("".join(lines))
|
||||
|
||||
received: list[LogEntry] = []
|
||||
reader = LogReader(log_file=log_file, consumer=received.append)
|
||||
|
||||
try:
|
||||
reader.start_watching()
|
||||
|
||||
with log_file.open("a") as f:
|
||||
f.write("2026-02-08T10:30:50.000000+00:00 1 2 INFO New message\n")
|
||||
|
||||
assert _wait_for(lambda: len(received) >= 1)
|
||||
|
||||
reader.stop_watching()
|
||||
|
||||
result = reader.get_logs(limit=10)
|
||||
assert len(result.entries) == 6
|
||||
assert result.entries[0].message == "New message"
|
||||
|
||||
finally:
|
||||
reader.shutdown()
|
||||
|
|
@ -225,6 +225,18 @@ class TestNuageClientGetChatAssistantUrl:
|
|||
url = await nuage.get_chat_assistant_url("exec-123")
|
||||
assert url == "https://chat.example.com/thread/123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chat_assistant_url_none(
|
||||
self, nuage: NuageClient, mock_client: MagicMock
|
||||
) -> None:
|
||||
mock_response = MagicMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.json.return_value = {"result": {"chat_url": None}}
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
url = await nuage.get_chat_assistant_url("exec-123")
|
||||
assert url is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_chat_assistant_url_failure(
|
||||
self, nuage: NuageClient, mock_client: MagicMock
|
||||
|
|
|
|||
|
|
@ -382,6 +382,31 @@ class TestTeleportServiceExecute:
|
|||
assert isinstance(events[4], TeleportAuthCompleteEvent)
|
||||
assert isinstance(events[-1], TeleportCompleteEvent)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_raises_when_chat_url_is_none(
|
||||
self,
|
||||
service: TeleportService,
|
||||
git_info: GitRepoInfo,
|
||||
mock_github_connected: MagicMock,
|
||||
) -> None:
|
||||
service._git.get_info = AsyncMock(return_value=git_info)
|
||||
service._git.is_commit_pushed = AsyncMock(return_value=True)
|
||||
|
||||
mock_nuage = MagicMock()
|
||||
mock_nuage.start_workflow = AsyncMock(return_value="exec-123")
|
||||
mock_nuage.get_github_integration = AsyncMock(
|
||||
return_value=mock_github_connected
|
||||
)
|
||||
mock_nuage.get_chat_assistant_url = AsyncMock(return_value=None)
|
||||
service._nuage = mock_nuage
|
||||
|
||||
session = TeleportSession()
|
||||
gen = service.execute("test prompt", session)
|
||||
|
||||
with pytest.raises(ServiceTeleportError, match="not available"):
|
||||
async for _ in gen:
|
||||
pass
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_uses_default_prompt_when_none(
|
||||
self,
|
||||
|
|
|
|||
50
tests/core/test_watcher.py
Normal file
50
tests/core/test_watcher.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vibe.core.autocompletion.file_indexer.watcher import WatchController
|
||||
|
||||
|
||||
class TestWatchControllerIsWatching:
|
||||
@pytest.fixture
|
||||
def watch_dir(self, tmp_path: Path) -> Path:
|
||||
return tmp_path / "watch"
|
||||
|
||||
@pytest.fixture
|
||||
def watcher(self) -> Generator[WatchController, None, None]:
|
||||
changes: list = []
|
||||
controller = WatchController(on_changes=lambda root, c: changes.extend(c))
|
||||
yield controller
|
||||
controller.stop()
|
||||
|
||||
def test_is_watching_false_initially(self, watcher: WatchController) -> None:
|
||||
assert watcher.is_watching is False
|
||||
|
||||
def test_is_watching_true_after_start(
|
||||
self, watcher: WatchController, watch_dir: Path
|
||||
) -> None:
|
||||
watch_dir.mkdir()
|
||||
watcher.start(watch_dir)
|
||||
assert watcher.is_watching is True
|
||||
|
||||
def test_is_watching_false_after_stop(
|
||||
self, watcher: WatchController, watch_dir: Path
|
||||
) -> None:
|
||||
watch_dir.mkdir()
|
||||
watcher.start(watch_dir)
|
||||
assert watcher.is_watching is True
|
||||
watcher.stop()
|
||||
assert watcher.is_watching is False
|
||||
|
||||
def test_is_watching_true_after_restart(
|
||||
self, watcher: WatchController, watch_dir: Path
|
||||
) -> None:
|
||||
watch_dir.mkdir()
|
||||
watcher.start(watch_dir)
|
||||
watcher.stop()
|
||||
assert watcher.is_watching is False
|
||||
watcher.start(watch_dir)
|
||||
assert watcher.is_watching is True
|
||||
|
|
@ -34,7 +34,7 @@ def _setup_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[
|
|||
|
||||
|
||||
def _make_read_file() -> ReadFile:
|
||||
return ReadFile(config=ReadFileToolConfig(), state=ReadFileState())
|
||||
return ReadFile(config_getter=lambda: ReadFileToolConfig(), state=ReadFileState())
|
||||
|
||||
|
||||
class TestGetResultExtra:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue