4e64544aae
CI / benchmark-publish (pull_request) Has been skipped
CI / quality (pull_request) Successful in 48s
CI / build (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 21s
CI / lint (pull_request) Successful in 3m15s
CI / typecheck (pull_request) Successful in 4m4s
CI / security (pull_request) Successful in 4m13s
CI / unit_tests (pull_request) Successful in 4m28s
CI / docker (pull_request) Successful in 1m52s
CI / integration_tests (pull_request) Successful in 7m14s
CI / coverage (pull_request) Successful in 12m49s
CI / e2e_tests (pull_request) Successful in 22m8s
CI / status-check (pull_request) Successful in 2s
CI / benchmark-regression (pull_request) Successful in 57m27s
Add explicit indexing timeout bounds and propagate timeout_seconds through the repo indexing service, utility walker, and CLI entrypoint so large repositories fail predictably instead of hanging. Add 10K-scale Behave/Robot verification and a dedicated benchmark path to validate that large-project indexing completes, persists status correctly, and remains measurable for regressions. Address review feedback by splitting oversized Behave and Robot support files into focused modules, trimming repo_indexing_service.py back under the 500-line limit, and rebasing the branch onto current master. ISSUES CLOSED: #851 # Conflicts: # CHANGELOG.md # robot/helper_m5_e2e_verification.py
455 lines
20 KiB
Python
455 lines
20 KiB
Python
"""Step definitions for features/repo_indexing.feature.
|
|
|
|
Tests for the repository indexing service (issue #195). All context
|
|
attributes use the ``repo_index_`` prefix to avoid collisions with
|
|
other step files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from behave import given, when # type: ignore[import-untyped]
|
|
from behave.runner import Context # type: ignore[import-untyped]
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from cleveragents.application.services.repo_indexing_service import (
|
|
RepoIndexingService,
|
|
)
|
|
from cleveragents.domain.models.core.repo_index import (
|
|
FileRecord,
|
|
IndexMetadata,
|
|
IndexStatus,
|
|
)
|
|
from cleveragents.infrastructure.database.models import Base
|
|
|
|
_RESOURCE_ID = "01KK0D8WNATFNEX2JMG5GKF6FM"
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_service(context: Context) -> RepoIndexingService:
|
|
"""Create an in-memory RepoIndexingService and store on context."""
|
|
engine = create_engine("sqlite:///:memory:", echo=False)
|
|
Base.metadata.create_all(engine)
|
|
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
|
service = RepoIndexingService(session_factory=factory)
|
|
context.repo_index_service = service # type: ignore[attr-defined]
|
|
return service
|
|
|
|
|
|
def _make_sample_project(context: Context) -> Path:
|
|
"""Create a temp directory with sample project files."""
|
|
tmpdir = Path(tempfile.mkdtemp())
|
|
(tmpdir / "main.py").write_text("print('hello world')\ndef main():\n pass\n")
|
|
(tmpdir / "utils.py").write_text("def helper():\n return 42\n")
|
|
(tmpdir / "index.ts").write_text("export const app = () => {};\n")
|
|
(tmpdir / "README.md").write_text("# Sample Project\n\nA test project.\n")
|
|
(tmpdir / "config.json").write_text('{"key": "value", "debug": true}\n')
|
|
(tmpdir / "Makefile").write_text("all:\n\t@echo hello\n")
|
|
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
|
|
context.add_cleanup(_cleanup_tmpdir, context)
|
|
return tmpdir
|
|
|
|
|
|
def _cleanup_tmpdir(context: Context) -> None:
|
|
"""Remove the temporary directory."""
|
|
tmpdir = getattr(context, "repo_index_tmpdir", None)
|
|
if tmpdir:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
# ── Given steps ──────────────────────────────────────────────────────
|
|
|
|
|
|
@given("a repo-index fresh in-memory indexing service")
|
|
def step_fresh_service(context: Context) -> None:
|
|
"""Set up a fresh in-memory RepoIndexingService."""
|
|
_make_service(context)
|
|
context.repo_index_result = None # type: ignore[attr-defined]
|
|
context.repo_index_original_result = None # type: ignore[attr-defined]
|
|
context.repo_index_error = None # type: ignore[attr-defined]
|
|
context.repo_index_removal_result = None # type: ignore[attr-defined]
|
|
|
|
|
|
@given("a repo-index temporary directory with sample project files")
|
|
def step_sample_project(context: Context) -> None:
|
|
"""Create a temporary directory with sample files."""
|
|
_make_sample_project(context)
|
|
|
|
|
|
@given("a repo-index temporary directory with sample project files and a 2MB file")
|
|
def step_sample_project_with_big_file(context: Context) -> None:
|
|
"""Create a temporary directory with sample files plus a 2MB file."""
|
|
tmpdir = _make_sample_project(context)
|
|
(tmpdir / "big_file.dat").write_bytes(b"x" * 2_000_000)
|
|
|
|
|
|
@given("a repo-index temporary directory with 10000 python files")
|
|
def step_large_directory_10000_files(context: Context) -> None:
|
|
"""Create a temporary directory containing 10,000 small Python files."""
|
|
tmpdir = Path(tempfile.mkdtemp())
|
|
created = 0
|
|
for i in range(100):
|
|
module_dir = tmpdir / f"module_{i:03d}"
|
|
module_dir.mkdir(parents=True, exist_ok=True)
|
|
for j in range(100):
|
|
(module_dir / f"file_{j:03d}.py").write_text(
|
|
f"def f_{i}_{j}():\n return {i + j}\n"
|
|
)
|
|
created += 1
|
|
|
|
assert created == 10_000, f"Expected 10000 files, created {created}"
|
|
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
|
|
context.add_cleanup(_cleanup_tmpdir, context)
|
|
|
|
|
|
@given("a repo-index empty temporary directory")
|
|
def step_empty_directory(context: Context) -> None:
|
|
"""Create an empty temporary directory."""
|
|
tmpdir = Path(tempfile.mkdtemp())
|
|
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
|
|
context.add_cleanup(_cleanup_tmpdir, context)
|
|
|
|
|
|
@given("a repo-index temporary directory with a file that fails hashing")
|
|
def step_dir_with_unhashable_file(context: Context) -> None:
|
|
"""Create a temp directory with a file that will fail during hashing.
|
|
|
|
We create a normal file here; the actual OSError is injected via
|
|
``unittest.mock.patch`` in the When step. This avoids reliance on
|
|
``chmod 0o000`` which is ineffective when running as root.
|
|
"""
|
|
tmpdir = Path(tempfile.mkdtemp())
|
|
(tmpdir / "visible.py").write_text("x = 1\n")
|
|
(tmpdir / "secret.dat").write_text("top secret\n")
|
|
context.repo_index_tmpdir = str(tmpdir) # type: ignore[attr-defined]
|
|
context.add_cleanup(_cleanup_tmpdir, context)
|
|
|
|
|
|
@given("I run repo-index full index on the sample directory")
|
|
def step_run_full_index_given(context: Context) -> None:
|
|
"""Run a full index (as a Given precondition)."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
context.repo_index_original_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
# ── When steps ───────────────────────────────────────────────────────
|
|
|
|
|
|
@when("I run repo-index full index on the sample directory")
|
|
def step_run_full_index(context: Context) -> None:
|
|
"""Run a full index."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
context.repo_index_original_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I run repo-index full index with hash failure on "{filename}"')
|
|
def step_run_full_index_with_hash_failure(context: Context, filename: str) -> None:
|
|
"""Run a full index where ``read_and_hash`` raises OSError for *filename*.
|
|
|
|
This simulates an unreadable file without relying on ``chmod 0o000``
|
|
(which is ineffective when the test runner is root).
|
|
We patch ``read_and_hash`` in the utils module where
|
|
``walk_and_index`` actually calls it.
|
|
"""
|
|
import cleveragents.application.services.repo_indexing_utils as utils_mod
|
|
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
original_read_and_hash = utils_mod.read_and_hash
|
|
|
|
def _failing_read(path: Path) -> tuple[str, int]:
|
|
if Path(path).name == filename:
|
|
raise OSError(f"Simulated read failure for {filename}")
|
|
return original_read_and_hash(path)
|
|
|
|
with patch.object(utils_mod, "read_and_hash", _failing_read):
|
|
result = service.index_resource(
|
|
_RESOURCE_ID,
|
|
context.repo_index_tmpdir, # type: ignore[attr-defined]
|
|
)
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
context.repo_index_original_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I query repo-index for the indexed resource")
|
|
def step_query_index(context: Context) -> None:
|
|
"""Query the persisted index."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
context.repo_index_retrieved = service.get_index(_RESOURCE_ID) # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I modify repo-index file "{filename}" with new content')
|
|
def step_modify_file(context: Context, filename: str) -> None:
|
|
"""Modify a file in the temp directory."""
|
|
filepath = Path(context.repo_index_tmpdir) / filename # type: ignore[attr-defined]
|
|
filepath.write_text("# Modified content\nprint('updated')\n" * 5)
|
|
|
|
|
|
@when("I run repo-index incremental refresh")
|
|
def step_run_refresh(context: Context) -> None:
|
|
"""Run incremental refresh."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
result = service.refresh_index(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I run repo-index full index with max file size {size:d} bytes")
|
|
def step_run_index_max_file_size(context: Context, size: int) -> None:
|
|
"""Run index with max_file_size policy."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
result = service.index_resource(
|
|
_RESOURCE_ID,
|
|
context.repo_index_tmpdir, # type: ignore[attr-defined]
|
|
max_file_size=size,
|
|
)
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I run repo-index full index with include globs "{globs}"')
|
|
def step_run_index_include_globs(context: Context, globs: str) -> None:
|
|
"""Run index with include globs."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
glob_tuple = tuple(g.strip() for g in globs.split(","))
|
|
result = service.index_resource(
|
|
_RESOURCE_ID,
|
|
context.repo_index_tmpdir, # type: ignore[attr-defined]
|
|
include_globs=glob_tuple,
|
|
)
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I run repo-index full index with exclude globs "{globs}"')
|
|
def step_run_index_exclude_globs(context: Context, globs: str) -> None:
|
|
"""Run index with exclude globs."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
glob_tuple = tuple(g.strip() for g in globs.split(","))
|
|
result = service.index_resource(
|
|
_RESOURCE_ID,
|
|
context.repo_index_tmpdir, # type: ignore[attr-defined]
|
|
exclude_globs=glob_tuple,
|
|
)
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I run repo-index full index with max total size {size:d} bytes")
|
|
def step_run_index_max_total_size(context: Context, size: int) -> None:
|
|
"""Run index with max_total_size policy."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
result = service.index_resource(
|
|
_RESOURCE_ID,
|
|
context.repo_index_tmpdir, # type: ignore[attr-defined]
|
|
max_total_size=size,
|
|
)
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I run repo-index full index with timeout of {seconds:d} seconds")
|
|
def step_run_index_with_timeout(context: Context, seconds: int) -> None:
|
|
"""Run index with an explicit timeout and capture elapsed time."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
started = time.monotonic()
|
|
result = service.index_resource(
|
|
_RESOURCE_ID,
|
|
context.repo_index_tmpdir, # type: ignore[attr-defined]
|
|
timeout_seconds=float(seconds),
|
|
)
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
context.repo_index_elapsed_seconds = time.monotonic() - started # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I remove the repo-index for the resource")
|
|
def step_remove_index(context: Context) -> None:
|
|
"""Remove the index."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
context.repo_index_removal_result = service.remove_index(_RESOURCE_ID) # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I remove the repo-index for a non-existent resource")
|
|
def step_remove_nonexistent(context: Context) -> None:
|
|
"""Attempt to remove an index that doesn't exist."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
context.repo_index_removal_result = service.remove_index(
|
|
"01KK0D8WNATFNEX2JMG5GKF6FN"
|
|
) # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I query repo-index status for the indexed resource")
|
|
def step_query_status(context: Context) -> None:
|
|
"""Query the index status (metadata only, no file records)."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
context.repo_index_status = service.get_index_status(_RESOURCE_ID) # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I attempt repo-index with a file path as root")
|
|
def step_index_file_root(context: Context) -> None:
|
|
"""Attempt indexing with a file path (not a directory)."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
file_path = Path(context.repo_index_tmpdir) / "main.py" # type: ignore[attr-defined]
|
|
try:
|
|
service.index_resource(_RESOURCE_ID, str(file_path))
|
|
except (ValueError, FileNotFoundError) as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I attempt repo-index refresh with non-existent path "{path}"')
|
|
def step_refresh_bad_path(context: Context, path: str) -> None:
|
|
"""Attempt refresh with a non-existent path."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
try:
|
|
service.refresh_index(_RESOURCE_ID, path)
|
|
except FileNotFoundError as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I attempt repo-index refresh with a file path as root")
|
|
def step_refresh_file_root(context: Context) -> None:
|
|
"""Attempt refresh with a file path (not a directory)."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
file_path = Path(context.repo_index_tmpdir) / "main.py" # type: ignore[attr-defined]
|
|
try:
|
|
service.refresh_index(_RESOURCE_ID, str(file_path))
|
|
except (ValueError, FileNotFoundError) as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when("repo-index walk is mocked to fail and I index a fresh resource")
|
|
def step_index_mock_walk_fail_fresh(context: Context) -> None:
|
|
"""Mock ``walk_and_index`` to raise OSError and attempt index.
|
|
|
|
This tests the fault handler at lines 300-325 of the service: when a
|
|
walk failure occurs on a *fresh* resource (no prior index), the
|
|
service should persist an ERROR status row.
|
|
|
|
We use ``unittest.mock.patch`` because ``os.walk`` silently skips
|
|
unreadable directories on Linux rather than raising.
|
|
"""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
with patch(
|
|
"cleveragents.application.services.repo_indexing_service.walk_and_index",
|
|
side_effect=OSError("Simulated walk failure"),
|
|
):
|
|
try:
|
|
service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
|
|
except Exception as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when("repo-index walk is mocked to fail and I re-index")
|
|
def step_reindex_mock_walk_fail(context: Context) -> None:
|
|
"""Mock ``walk_and_index`` to raise OSError and attempt re-index.
|
|
|
|
Tests the fault handler when a prior index exists: the service should
|
|
preserve the previous good index and re-raise.
|
|
"""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
with patch(
|
|
"cleveragents.application.services.repo_indexing_service.walk_and_index",
|
|
side_effect=OSError("Simulated walk failure"),
|
|
):
|
|
try:
|
|
service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
|
|
except Exception as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when("repo-index walk is mocked to fail and I refresh")
|
|
def step_refresh_mock_walk_fail(context: Context) -> None:
|
|
"""Mock ``walk_and_index`` to raise OSError and attempt refresh.
|
|
|
|
Tests the fault handler in ``refresh_index``: the service should
|
|
preserve the previous good index and re-raise.
|
|
"""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
with patch(
|
|
"cleveragents.application.services.repo_indexing_service.walk_and_index",
|
|
side_effect=OSError("Simulated walk failure"),
|
|
):
|
|
try:
|
|
service.refresh_index(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
|
|
except Exception as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I add a new file "{filename}" and re-index')
|
|
def step_add_file_reindex(context: Context, filename: str) -> None:
|
|
"""Add a new file and re-index the same resource."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
filepath = Path(context.repo_index_tmpdir) / filename # type: ignore[attr-defined]
|
|
filepath.write_text("# New file\ndef new_func(): pass\n")
|
|
result = service.index_resource(_RESOURCE_ID, context.repo_index_tmpdir) # type: ignore[attr-defined]
|
|
context.repo_index_result = result # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I create a FileRecord with a naive datetime")
|
|
def step_create_filerecord_naive(context: Context) -> None:
|
|
"""Create a FileRecord with a naive (no tzinfo) datetime."""
|
|
from datetime import datetime as dt
|
|
|
|
naive = dt(2025, 6, 15, 12, 0, 0)
|
|
context.repo_index_filerecord = FileRecord( # type: ignore[attr-defined]
|
|
path="test.py",
|
|
content_hash="a" * 64,
|
|
token_count=10,
|
|
language="python",
|
|
size_bytes=40,
|
|
last_modified=naive,
|
|
)
|
|
|
|
|
|
@when("I create an IndexMetadata with a naive datetime")
|
|
def step_create_metadata_naive(context: Context) -> None:
|
|
"""Create an IndexMetadata with a naive (no tzinfo) datetime."""
|
|
from datetime import datetime as dt
|
|
|
|
naive = dt(2025, 6, 15, 12, 0, 0)
|
|
context.repo_index_metadata_naive = IndexMetadata( # type: ignore[attr-defined]
|
|
index_id="01KK0D8WNATFNEX2JMG5GKF6FM",
|
|
resource_id="01KK0D8WNATFNEX2JMG5GKF6FN",
|
|
indexed_at=naive,
|
|
file_count=0,
|
|
token_estimate=0,
|
|
primary_language="unknown",
|
|
status=IndexStatus.READY,
|
|
)
|
|
|
|
|
|
@when("I attempt repo-index with invalid ULID resource ID")
|
|
def step_index_invalid_ulid(context: Context) -> None:
|
|
"""Attempt indexing with an invalid ULID resource ID."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
try:
|
|
service.index_resource("not-a-valid-ulid", tempfile.gettempdir())
|
|
except ValueError as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when("I attempt repo-index with empty resource ID")
|
|
def step_index_empty_id(context: Context) -> None:
|
|
"""Attempt indexing with empty resource ID."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
try:
|
|
service.index_resource("", tempfile.gettempdir())
|
|
except (ValueError, FileNotFoundError) as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|
|
|
|
|
|
@when('I attempt repo-index with non-existent path "{path}"')
|
|
def step_index_bad_path(context: Context, path: str) -> None:
|
|
"""Attempt indexing with a non-existent path."""
|
|
service: RepoIndexingService = context.repo_index_service # type: ignore[attr-defined]
|
|
try:
|
|
service.index_resource(_RESOURCE_ID, path)
|
|
except FileNotFoundError as exc:
|
|
context.repo_index_error = exc # type: ignore[attr-defined]
|