2764fcef5c
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 39s
CI / security (pull_request) Successful in 52s
CI / unit_tests (pull_request) Successful in 2m51s
CI / integration_tests (pull_request) Successful in 3m41s
CI / docker (pull_request) Successful in 56s
CI / e2e_tests (pull_request) Successful in 3m56s
CI / coverage (pull_request) Successful in 6m28s
CI / benchmark-regression (pull_request) Successful in 38m39s
Address review-driven fixes across actor schema, preflight guardrails, docs/examples, and Behave/Robot coverage: unify preflight warning behavior with shared role-warning logic, resolve actor-name to config payloads in production preflight flow, harden response_format validation/coercion edge cases, extract duplicated helper logic, and expand negative-path test coverage. Also fix cross-scenario patcher leakage in step modules to eliminate full-run-only coverage failures.
496 lines
16 KiB
Python
496 lines
16 KiB
Python
"""Robot Framework helper for UKO Indexer smoke tests.
|
|
|
|
Provides a CLI-style interface for Robot to invoke UKOIndexer creation,
|
|
index lifecycle operations, graceful degradation, and provenance tracking.
|
|
Exit code 0 = success, 1 = failure.
|
|
|
|
Usage:
|
|
python robot/helper_uko_indexer.py <command>
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
# Ensure the src directory is on the import path.
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
if _SRC not in sys.path:
|
|
sys.path.insert(0, _SRC)
|
|
|
|
from cleveragents.application.services.uko_indexer import UKOIndexer # noqa: E402
|
|
from cleveragents.application.services.uko_indexer_protocols import ( # noqa: E402
|
|
ContentReader,
|
|
DefaultLifecycleHook,
|
|
LocationContentReader,
|
|
)
|
|
from cleveragents.domain.models.acms.analyzers import ( # noqa: E402
|
|
AnalyzerRegistry,
|
|
)
|
|
from cleveragents.domain.models.acms.index_backends import ( # noqa: E402
|
|
IndexedDocument,
|
|
SearchResult,
|
|
)
|
|
from cleveragents.domain.models.acms.index_stubs import ( # noqa: E402
|
|
InMemoryGraphIndexBackend,
|
|
InMemoryTextIndexBackend,
|
|
InMemoryVectorIndexBackend,
|
|
)
|
|
from cleveragents.domain.models.acms.provenance import ( # noqa: E402
|
|
IndexResult,
|
|
ProvenanceMetadata,
|
|
)
|
|
from cleveragents.domain.models.acms.python_analyzer import ( # noqa: E402
|
|
PythonAnalyzer,
|
|
)
|
|
from cleveragents.domain.models.core.resource import Resource # noqa: E402
|
|
|
|
# Ensure features/ is importable for shared mocks
|
|
_FEATURES = str(Path(__file__).resolve().parents[1])
|
|
if _FEATURES not in sys.path:
|
|
sys.path.insert(0, _FEATURES)
|
|
|
|
from features.mocks.uko_indexer_mocks import InMemoryContentReader # noqa: E402
|
|
|
|
ULID_1 = "01HQ8ZDRX50000000000000001"
|
|
ULID_2 = "01HQ8ZDRX50000000000000002"
|
|
PROJECT = "local/test"
|
|
|
|
|
|
def _make_resource(
|
|
resource_id: str,
|
|
location: str = "src/example.py",
|
|
) -> Resource:
|
|
"""Create a minimal Resource for testing."""
|
|
return Resource(
|
|
resource_id=resource_id,
|
|
resource_type_name="git-checkout",
|
|
location=location,
|
|
classification="physical",
|
|
)
|
|
|
|
|
|
def _make_indexer(
|
|
*,
|
|
text: bool = True,
|
|
vector: bool = True,
|
|
) -> tuple[
|
|
UKOIndexer,
|
|
InMemoryContentReader,
|
|
InMemoryTextIndexBackend | None,
|
|
InMemoryVectorIndexBackend | None,
|
|
InMemoryGraphIndexBackend,
|
|
]:
|
|
"""Create a fully-wired UKOIndexer with in-memory backends."""
|
|
registry = AnalyzerRegistry()
|
|
registry.register(PythonAnalyzer())
|
|
reader = InMemoryContentReader()
|
|
graph = InMemoryGraphIndexBackend()
|
|
text_be = InMemoryTextIndexBackend() if text else None
|
|
vector_be = InMemoryVectorIndexBackend() if vector else None
|
|
indexer = UKOIndexer(
|
|
analyzer_registry=registry,
|
|
graph_backend=graph,
|
|
text_backend=text_be,
|
|
vector_backend=vector_be,
|
|
content_reader=reader,
|
|
)
|
|
return (
|
|
indexer,
|
|
reader,
|
|
text_be,
|
|
vector_be,
|
|
graph,
|
|
)
|
|
|
|
|
|
def cmd_index_resource() -> int:
|
|
"""Test basic index_resource pipeline."""
|
|
try:
|
|
indexer, reader, _text, _vec, _graph = _make_indexer()
|
|
resource = _make_resource(ULID_1)
|
|
code = '"""Module doc."""\nclass Foo:\n """Class doc."""\n pass\n'
|
|
reader.set_content(ULID_1, code)
|
|
result = indexer.index_resource(resource, project=PROJECT)
|
|
|
|
assert result.resource_id == ULID_1
|
|
assert result.triple_count > 0
|
|
assert result.analyzer_domain == "python"
|
|
assert result.text_docs_indexed == 1
|
|
assert result.embeddings_indexed == 1
|
|
assert len(result.errors) == 0
|
|
assert indexer.indexed_resource_count == 1
|
|
|
|
print("uko-indexer-index-resource-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-index-resource-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_remove_resource() -> int:
|
|
"""Test remove_resource cleanup including backend state."""
|
|
try:
|
|
indexer, reader, text_be, vec_be, graph_be = _make_indexer()
|
|
resource = _make_resource(ULID_1)
|
|
reader.set_content(ULID_1, "x = 1\n")
|
|
indexer.index_resource(resource, project=PROJECT)
|
|
assert indexer.indexed_resource_count == 1
|
|
|
|
# Verify backends have data before removal
|
|
assert graph_be.triple_count(PROJECT) > 0
|
|
assert text_be is not None and text_be.document_count > 0
|
|
assert vec_be is not None and vec_be.embedding_count > 0
|
|
|
|
indexer.remove_resource(ULID_1, project=PROJECT)
|
|
assert indexer.indexed_resource_count == 0
|
|
|
|
# Verify backends are cleaned up after removal
|
|
assert graph_be.triple_count(PROJECT) == 0
|
|
assert text_be.document_count == 0
|
|
assert vec_be.embedding_count == 0
|
|
|
|
print("uko-indexer-remove-resource-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-remove-resource-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_reindex_resource() -> int:
|
|
"""Test reindex_resource (remove + re-index)."""
|
|
try:
|
|
indexer, reader, _text, _vec, _graph = _make_indexer()
|
|
resource = _make_resource(ULID_1)
|
|
reader.set_content(ULID_1, "x = 1\n")
|
|
result1 = indexer.index_resource(resource, project=PROJECT)
|
|
assert result1.triple_count > 0
|
|
|
|
# Update content and reindex
|
|
reader.set_content(
|
|
ULID_1,
|
|
'"""Reindexed."""\nclass Bar:\n pass\n',
|
|
)
|
|
result2 = indexer.reindex_resource(resource, project=PROJECT)
|
|
assert result2.resource_id == ULID_1
|
|
assert result2.triple_count > 0
|
|
assert indexer.indexed_resource_count == 1
|
|
|
|
print("uko-indexer-reindex-resource-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-reindex-resource-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_graceful_degradation() -> int:
|
|
"""Test graceful degradation without text/vector backends."""
|
|
try:
|
|
indexer, reader, _, _, _graph = _make_indexer(text=False, vector=False)
|
|
resource = _make_resource(ULID_1)
|
|
reader.set_content(ULID_1, "y = 2\n")
|
|
result = indexer.index_resource(resource, project=PROJECT)
|
|
|
|
assert result.triple_count > 0
|
|
assert result.text_docs_indexed == 0
|
|
assert result.embeddings_indexed == 0
|
|
assert not indexer.has_text_backend
|
|
assert not indexer.has_vector_backend
|
|
assert len(result.errors) == 0
|
|
|
|
print("uko-indexer-graceful-degradation-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-graceful-degradation-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_provenance() -> int:
|
|
"""Test provenance metadata is attached to triples."""
|
|
try:
|
|
# Verify ProvenanceMetadata construction
|
|
prov = ProvenanceMetadata(
|
|
source_resource=ULID_1,
|
|
source_path="src/example.py",
|
|
)
|
|
assert prov.is_current is True
|
|
assert prov.valid_from is not None
|
|
|
|
# Verify provenance is attached during indexing via IndexResult
|
|
indexer, reader, _, _, _graph = _make_indexer()
|
|
resource = _make_resource(ULID_1)
|
|
reader.set_content(ULID_1, "z = 3\n")
|
|
result = indexer.index_resource(resource, project=PROJECT)
|
|
assert result.triple_count > 0
|
|
assert result.resource_id == ULID_1
|
|
|
|
print("uko-indexer-provenance-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-provenance-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_index_backends() -> int:
|
|
"""Test in-memory index backend operations."""
|
|
try:
|
|
# Text backend
|
|
text_be = InMemoryTextIndexBackend()
|
|
text_be.index_document(PROJECT, "doc1", "hello world", {"key": "val"})
|
|
results = text_be.search(PROJECT, "hello", limit=5)
|
|
assert len(results) == 1
|
|
assert results[0].doc_id == "doc1"
|
|
text_be.remove_document(PROJECT, "doc1")
|
|
results = text_be.search(PROJECT, "hello", limit=5)
|
|
assert len(results) == 0
|
|
|
|
# Vector backend
|
|
vec_be = InMemoryVectorIndexBackend()
|
|
vec_be.index_embedding(PROJECT, "emb1", [1.0, 2.0, 3.0], {"key": "val"})
|
|
results = vec_be.search_similar(PROJECT, [1.0, 2.0, 3.0], limit=5)
|
|
assert len(results) == 1
|
|
assert results[0].doc_id == "emb1"
|
|
vec_be.remove_embedding(PROJECT, "emb1")
|
|
results = vec_be.search_similar(PROJECT, [1.0, 2.0, 3.0], limit=5)
|
|
assert len(results) == 0
|
|
|
|
# Graph backend
|
|
graph_be = InMemoryGraphIndexBackend()
|
|
graph_be.add_triple(PROJECT, "s1", "p1", "o1")
|
|
bindings = graph_be.query(PROJECT, "SELECT * WHERE { ?s ?p ?o }")
|
|
assert len(bindings) == 1
|
|
assert bindings[0]["s"] == "s1"
|
|
assert bindings[0]["p"] == "p1"
|
|
assert bindings[0]["o"] == "o1"
|
|
graph_be.remove_triples(PROJECT, subject="s1", predicate=None, obj=None)
|
|
bindings = graph_be.query(PROJECT, "SELECT * WHERE { ?s ?p ?o }")
|
|
assert len(bindings) == 0
|
|
|
|
# Validation
|
|
doc = IndexedDocument(project=PROJECT, doc_id="d1", char_count=10)
|
|
assert doc.char_count == 10
|
|
|
|
sr = SearchResult(doc_id="d1", score=0.5, content="test")
|
|
assert sr.score == 0.5
|
|
|
|
print("uko-indexer-index-backends-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-index-backends-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_validation() -> int:
|
|
"""Test input validation guards."""
|
|
try:
|
|
indexer, reader, _, _, _ = _make_indexer()
|
|
resource = _make_resource(ULID_1)
|
|
reader.set_content(ULID_1, "a = 1\n")
|
|
|
|
# Empty project
|
|
try:
|
|
indexer.index_resource(resource, project="")
|
|
print("uko-indexer-validation-fail: no ValueError for empty project")
|
|
return 1
|
|
except ValueError:
|
|
pass
|
|
|
|
# Empty resource_id on remove
|
|
try:
|
|
indexer.remove_resource("", project=PROJECT)
|
|
print("uko-indexer-validation-fail: no ValueError for empty resource_id")
|
|
return 1
|
|
except ValueError:
|
|
pass
|
|
|
|
# Invalid analyzer_registry type
|
|
try:
|
|
UKOIndexer(
|
|
analyzer_registry="not_a_registry", # type: ignore[arg-type]
|
|
graph_backend=InMemoryGraphIndexBackend(),
|
|
)
|
|
print("uko-indexer-validation-fail: no TypeError for invalid registry")
|
|
return 1
|
|
except TypeError:
|
|
pass
|
|
|
|
# SearchResult score out of range
|
|
try:
|
|
SearchResult(doc_id="x", score=1.5, content="test")
|
|
print("uko-indexer-validation-fail: no ValueError for score > 1.0")
|
|
return 1
|
|
except ValueError:
|
|
pass
|
|
|
|
print("uko-indexer-validation-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-validation-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_protocol_compliance() -> int:
|
|
"""Verify protocol compliance for key types."""
|
|
try:
|
|
reader = InMemoryContentReader()
|
|
assert isinstance(reader, ContentReader)
|
|
|
|
hook = DefaultLifecycleHook()
|
|
# DefaultLifecycleHook logs but doesn't fail
|
|
result = IndexResult(
|
|
resource_id=ULID_1,
|
|
analyzer_domain="python",
|
|
triple_count=5,
|
|
)
|
|
hook.on_indexed(result)
|
|
hook.on_removed(ULID_1, PROJECT)
|
|
hook.on_error(ULID_1, "test error")
|
|
|
|
# LocationContentReader protocol compliance
|
|
loc_reader = LocationContentReader()
|
|
assert isinstance(loc_reader, ContentReader)
|
|
|
|
print("uko-indexer-protocol-compliance-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-protocol-compliance-fail: {exc}")
|
|
return 1
|
|
|
|
|
|
def cmd_file_watching() -> int:
|
|
"""Test ResourceFileWatcher lifecycle, change detection, and EventBus."""
|
|
import threading
|
|
import time
|
|
|
|
from watchdog.events import FileDeletedEvent, FileModifiedEvent
|
|
|
|
from cleveragents.application.services.resource_file_watcher import (
|
|
FileChangeType,
|
|
ResourceFileWatcher,
|
|
)
|
|
from cleveragents.infrastructure.events.models import DomainEvent
|
|
from cleveragents.infrastructure.events.types import EventType
|
|
|
|
class _MiniEventBus:
|
|
def __init__(self) -> None:
|
|
self.events: list[DomainEvent] = []
|
|
self._ev = threading.Event()
|
|
|
|
def emit(self, event: DomainEvent) -> None:
|
|
self.events.append(event)
|
|
self._ev.set()
|
|
|
|
def subscribe(self, event_type: EventType, handler: object) -> None:
|
|
_ = event_type, handler
|
|
|
|
try:
|
|
import tempfile as _tf
|
|
from pathlib import Path as _P
|
|
|
|
tmpdir = _tf.mkdtemp()
|
|
fpath = _P(tmpdir) / "watched.py"
|
|
fpath.write_text("x = 1\n")
|
|
|
|
changes: list[tuple[str, str, FileChangeType]] = []
|
|
cb_event = threading.Event()
|
|
|
|
def _on_change(rid: str, proj: str, ct: FileChangeType) -> None:
|
|
changes.append((rid, proj, ct))
|
|
cb_event.set()
|
|
|
|
bus = _MiniEventBus()
|
|
watcher = ResourceFileWatcher(
|
|
on_change=_on_change,
|
|
event_bus=bus,
|
|
debounce_seconds=0.05,
|
|
)
|
|
|
|
# 1. Lifecycle
|
|
# Avoid creating real OS-level inotify watches in CI/parallel runs,
|
|
# which can fail with ENOSPC when global watch limits are exhausted.
|
|
# We only need the watcher in a "running" state to exercise internal
|
|
# debounce/callback/event-bus behavior via synthetic watchdog events.
|
|
assert not watcher.is_running
|
|
watcher._running = True
|
|
assert watcher.is_running
|
|
|
|
# 2. Watch a file
|
|
watcher.watch(fpath, resource_id=ULID_1, project=PROJECT)
|
|
assert len(watcher.watched_paths) == 1
|
|
|
|
# 3. Simulate modification
|
|
ev = FileModifiedEvent(str(fpath.resolve()))
|
|
watcher._handle_fs_event(ev)
|
|
assert cb_event.wait(timeout=2.0), "Callback did not fire"
|
|
assert changes[-1][0] == ULID_1
|
|
assert changes[-1][2] == FileChangeType.MODIFIED
|
|
|
|
# 4. EventBus received event
|
|
time.sleep(0.1)
|
|
assert len(bus.events) > 0
|
|
assert bus.events[-1].event_type == EventType.RESOURCE_MODIFIED
|
|
assert bus.events[-1].details["resource_id"] == ULID_1
|
|
|
|
# 5. Simulate deletion
|
|
cb_event.clear()
|
|
ev2 = FileDeletedEvent(str(fpath.resolve()))
|
|
watcher._handle_fs_event(ev2)
|
|
assert cb_event.wait(timeout=2.0), "Callback did not fire for delete"
|
|
assert changes[-1][2] == FileChangeType.DELETED
|
|
|
|
# 6. Unwatch
|
|
watcher.unwatch(fpath)
|
|
assert len(watcher.watched_paths) == 0
|
|
|
|
# 7. Stop
|
|
watcher.stop()
|
|
assert not watcher.is_running
|
|
|
|
# Cleanup
|
|
fpath.unlink(missing_ok=True)
|
|
_P(tmpdir).rmdir()
|
|
|
|
print("uko-indexer-file-watching-ok")
|
|
return 0
|
|
except Exception as exc:
|
|
print(f"uko-indexer-file-watching-fail: {exc}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return 1
|
|
|
|
|
|
COMMANDS: dict[str, Callable[[], int]] = {
|
|
"index-resource": cmd_index_resource,
|
|
"remove-resource": cmd_remove_resource,
|
|
"reindex-resource": cmd_reindex_resource,
|
|
"graceful-degradation": cmd_graceful_degradation,
|
|
"provenance": cmd_provenance,
|
|
"index-backends": cmd_index_backends,
|
|
"validation": cmd_validation,
|
|
"protocol-compliance": cmd_protocol_compliance,
|
|
"file-watching": cmd_file_watching,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
"""Entry point called by Robot Framework ``Run Process``."""
|
|
if len(sys.argv) < 2:
|
|
print("Usage: helper_uko_indexer.py <command>")
|
|
print(f"Commands: {', '.join(COMMANDS)}")
|
|
return 1
|
|
|
|
command: str = sys.argv[1]
|
|
handler = COMMANDS.get(command)
|
|
if handler is None:
|
|
print(f"Unknown command: {command}")
|
|
print(f"Commands: {', '.join(COMMANDS)}")
|
|
return 1
|
|
|
|
return handler()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|