Co-authored-by: Carlo <carloantonio.patti@mistral.ai> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
349 lines
11 KiB
Python
349 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Generator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from vibe.core.autocompletion.file_indexer import FileIndexer
|
|
|
|
# This suite runs against the real filesystem and watcher. A faked store/watcher
|
|
# split would be faster to unit-test, but given time constraints and the low churn
|
|
# expected for this feature, integration coverage was chosen as a trade-off.
|
|
|
|
|
|
@pytest.fixture
|
|
def file_indexer() -> Generator[FileIndexer]:
|
|
indexer = FileIndexer(should_enable_watcher=lambda: True)
|
|
yield indexer
|
|
indexer.shutdown()
|
|
|
|
|
|
def _wait_for(condition: Callable[[], bool], timeout=3.0) -> bool:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if condition():
|
|
return True
|
|
time.sleep(0.05)
|
|
return False
|
|
|
|
|
|
def _assert_index_state_stable(
|
|
file_indexer: FileIndexer,
|
|
expected_entries: set[str],
|
|
expected_incremental_updates: int,
|
|
duration: float = 1.0,
|
|
) -> None:
|
|
deadline = time.monotonic() + duration
|
|
while time.monotonic() < deadline:
|
|
current_entries = {entry.rel for entry in file_indexer.get_index(Path("."))}
|
|
assert current_entries == expected_entries
|
|
assert file_indexer.stats.incremental_updates == expected_incremental_updates
|
|
time.sleep(0.1)
|
|
|
|
|
|
def test_updates_index_on_file_creation(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
file_indexer.get_index(Path("."))
|
|
|
|
target = tmp_path / "new_file.py"
|
|
target.write_text("", encoding="utf-8")
|
|
|
|
assert _wait_for(
|
|
lambda: any(
|
|
entry.rel == target.name for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
)
|
|
|
|
|
|
def test_updates_index_on_file_deletion(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
target = tmp_path / "new_file.py"
|
|
target.write_text("", encoding="utf-8")
|
|
file_indexer.get_index(Path("."))
|
|
|
|
target.unlink()
|
|
|
|
assert _wait_for(
|
|
lambda: all(
|
|
entry.rel != target.name for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
)
|
|
|
|
|
|
def test_updates_index_on_file_rename(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
old_file = tmp_path / "old_name.py"
|
|
old_file.write_text("", encoding="utf-8")
|
|
file_indexer.get_index(Path("."))
|
|
|
|
new_file = tmp_path / "new_name.py"
|
|
old_file.rename(new_file)
|
|
|
|
assert _wait_for(
|
|
lambda: (
|
|
all(
|
|
entry.rel != old_file.name
|
|
for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
and any(
|
|
entry.rel == new_file.name
|
|
for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def test_updates_index_on_folder_rename(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
old_folder = tmp_path / "old_folder"
|
|
old_folder.mkdir()
|
|
number_of_files = 5
|
|
file_names = [f"file{i}.py" for i in range(1, number_of_files + 1)]
|
|
old_file_paths = [old_folder / name for name in file_names]
|
|
for file_path in old_file_paths:
|
|
file_path.write_text("", encoding="utf-8")
|
|
file_indexer.get_index(Path("."))
|
|
|
|
new_folder = tmp_path / "new_folder"
|
|
old_folder.rename(new_folder)
|
|
|
|
assert _wait_for(
|
|
lambda: (
|
|
entries := file_indexer.get_index(Path(".")),
|
|
all(not entry.rel.startswith("old_folder/") for entry in entries)
|
|
and all(
|
|
any(entry.rel == f"new_folder/{name}" for entry in entries)
|
|
for name in file_names
|
|
),
|
|
)[1]
|
|
)
|
|
|
|
|
|
def test_updates_index_incrementally_by_default(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
file_indexer.get_index(Path("."))
|
|
|
|
rebuilds_before = file_indexer.stats.rebuilds
|
|
incremental_before = file_indexer.stats.incremental_updates
|
|
|
|
target = tmp_path / "stats_file.py"
|
|
target.write_text("", encoding="utf-8")
|
|
|
|
assert _wait_for(
|
|
lambda: any(
|
|
entry.rel == target.name for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
)
|
|
|
|
assert file_indexer.stats.rebuilds == rebuilds_before
|
|
assert file_indexer.stats.incremental_updates >= incremental_before + 1
|
|
|
|
|
|
def test_rebuilds_index_when_mass_change_threshold_is_exceeded(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
mass_change_threshold = 5
|
|
# in an ideal world, we would use "threshold + 1", but in reality, we need to test with a
|
|
# number of files important enough to MAKE SURE that a batch of >= threshold events will be
|
|
# detected by the watcher
|
|
number_of_files = mass_change_threshold * 3
|
|
monkeypatch.chdir(tmp_path)
|
|
indexer = FileIndexer(
|
|
mass_change_threshold=mass_change_threshold, should_enable_watcher=lambda: True
|
|
)
|
|
try:
|
|
indexer.get_index(Path("."))
|
|
rebuilds_before = indexer.stats.rebuilds
|
|
|
|
ThreadPoolExecutor(max_workers=number_of_files).map(
|
|
lambda i: (tmp_path / f"bulk{i}.py").write_text("", encoding="utf-8"),
|
|
range(number_of_files),
|
|
)
|
|
|
|
assert _wait_for(lambda: len(indexer.get_index(Path("."))) == number_of_files)
|
|
# we do not assert that "incremental_updates" did not change,
|
|
# as the watcher potentially reported some batches of events that were
|
|
# smaller than the threshold
|
|
assert indexer.stats.rebuilds >= rebuilds_before + 1
|
|
finally:
|
|
indexer.shutdown()
|
|
|
|
|
|
def test_switching_between_roots_restarts_index(
|
|
tmp_path: Path,
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
file_indexer: FileIndexer,
|
|
) -> None:
|
|
first_root = tmp_path
|
|
second_root = tmp_path_factory.mktemp("second-root")
|
|
(first_root / "first.py").write_text("", encoding="utf-8")
|
|
(second_root / "second.py").write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.chdir(first_root)
|
|
assert _wait_for(
|
|
lambda: any(
|
|
entry.rel == "first.py" for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
)
|
|
|
|
monkeypatch.chdir(second_root)
|
|
assert _wait_for(
|
|
lambda: all(
|
|
entry.rel != "first.py" for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
)
|
|
assert _wait_for(
|
|
lambda: any(
|
|
entry.rel == "second.py" for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
)
|
|
|
|
|
|
def test_watcher_failure_does_not_break_existing_index(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
seed = tmp_path / "seed.py"
|
|
seed.write_text("", encoding="utf-8")
|
|
file_indexer.get_index(Path("."))
|
|
|
|
def boom(*_: object, **__: object) -> None:
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(file_indexer._store, "apply_changes", boom)
|
|
|
|
(tmp_path / "new_file.py").write_text("", encoding="utf-8")
|
|
|
|
assert _wait_for(
|
|
lambda: (
|
|
entries := file_indexer.get_index(Path(".")),
|
|
# new file was not added: watcher failed
|
|
all(entry.rel != "new_file.py" for entry in entries)
|
|
# but the existing index is still intact
|
|
and all(entry.rel == "seed.py" for entry in entries),
|
|
)[1]
|
|
)
|
|
|
|
|
|
def test_shutdown_cleans_up_resources(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
(tmp_path / "test.txt").write_text("", encoding="utf-8")
|
|
file_indexer = FileIndexer()
|
|
file_indexer.get_index(Path("."))
|
|
|
|
file_indexer.shutdown()
|
|
assert file_indexer.get_index(Path(".")) == []
|
|
|
|
|
|
def test_watcher_is_disabled_by_default(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
file_indexer = FileIndexer()
|
|
try:
|
|
baseline_entries = {entry.rel for entry in file_indexer.get_index(Path("."))}
|
|
incremental_before = file_indexer.stats.incremental_updates
|
|
(tmp_path / "file.py").write_text("", encoding="utf-8")
|
|
|
|
_assert_index_state_stable(
|
|
file_indexer=file_indexer,
|
|
expected_entries=baseline_entries,
|
|
expected_incremental_updates=incremental_before,
|
|
)
|
|
finally:
|
|
file_indexer.shutdown()
|
|
|
|
|
|
def test_disabling_watcher_stops_runtime_updates(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
watcher_enabled = True
|
|
file_indexer = FileIndexer(should_enable_watcher=lambda: watcher_enabled)
|
|
try:
|
|
tracked = tmp_path / "tracked.py"
|
|
tracked.write_text("", encoding="utf-8")
|
|
file_indexer.get_index(Path("."))
|
|
assert any(
|
|
entry.rel == "tracked.py" for entry in file_indexer.get_index(Path("."))
|
|
)
|
|
|
|
watcher_enabled = False
|
|
file_indexer.get_index(Path("."))
|
|
|
|
expected_entries = {entry.rel for entry in file_indexer.get_index(Path("."))}
|
|
incremental_before = file_indexer.stats.incremental_updates
|
|
tracked.unlink()
|
|
|
|
_assert_index_state_stable(
|
|
file_indexer=file_indexer,
|
|
expected_entries=expected_entries,
|
|
expected_incremental_updates=incremental_before,
|
|
)
|
|
finally:
|
|
file_indexer.shutdown()
|
|
|
|
|
|
def _current_entries(file_indexer: FileIndexer) -> set[str]:
|
|
return {entry.rel for entry in file_indexer.get_index(Path("."))}
|
|
|
|
|
|
def _assert_created_file_is_not_indexed(
|
|
file_indexer: FileIndexer, tmp_path: Path, filename: str
|
|
) -> None:
|
|
expected_entries = _current_entries(file_indexer)
|
|
expected_incremental_updates = file_indexer.stats.incremental_updates
|
|
(tmp_path / filename).write_text("", encoding="utf-8")
|
|
|
|
_assert_index_state_stable(
|
|
file_indexer=file_indexer,
|
|
expected_entries=expected_entries,
|
|
expected_incremental_updates=expected_incremental_updates,
|
|
)
|
|
|
|
|
|
def _assert_created_file_is_indexed(
|
|
file_indexer: FileIndexer, tmp_path: Path, filename: str
|
|
) -> None:
|
|
incremental_before = file_indexer.stats.incremental_updates
|
|
(tmp_path / filename).write_text("", encoding="utf-8")
|
|
|
|
assert _wait_for(lambda: filename in _current_entries(file_indexer))
|
|
assert file_indexer.stats.incremental_updates >= incremental_before + 1
|
|
|
|
|
|
def test_watcher_toggle_flow_off_on_off(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
watcher_enabled = False
|
|
file_indexer = FileIndexer(should_enable_watcher=lambda: watcher_enabled)
|
|
try:
|
|
file_indexer.get_index(Path("."))
|
|
_assert_created_file_is_not_indexed(file_indexer, tmp_path, "off_before.py")
|
|
|
|
watcher_enabled = True
|
|
file_indexer.get_index(Path("."))
|
|
_assert_created_file_is_indexed(file_indexer, tmp_path, "on_file.py")
|
|
|
|
watcher_enabled = False
|
|
file_indexer.get_index(Path("."))
|
|
_assert_created_file_is_not_indexed(file_indexer, tmp_path, "off_after.py")
|
|
finally:
|
|
file_indexer.shutdown()
|