test(memory): E2E coverage for 4 scenarios (#102)
* test(memory): E2E coverage for 4 scenarios * docs(handoff): set PR number * style(memory): ruff format e2e test * docs(project-map): add test_memory_tools_e2e.py after PR#102 --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
45bbdc50f3
commit
9d1373a51b
4 changed files with 338 additions and 0 deletions
45
docs/decisions/044-pr-102-e2e-memory-tests.md
Normal file
45
docs/decisions/044-pr-102-e2e-memory-tests.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# ADR-044: E2E test structure for hybrid memory (subprocess-based)
|
||||
|
||||
## Статус
|
||||
Accepted (2026-07-26)
|
||||
|
||||
## Контекст
|
||||
|
||||
Гибридная память (keyword ripgrep + semantic Python OpenRouter) после рефактора
|
||||
(PR #100 lazy-init, PR #101 5 TS tools) нуждалась в E2E гарантии стабильности.
|
||||
Существующие unit-тесты (`test_search.py`, `test_index.py`, `test_embedder.py`)
|
||||
мокают `embed_texts` — не проверяют реальный keyword path (ripgrep subprocess) и
|
||||
не проверяют fallback при реальном падении OpenRouter.
|
||||
|
||||
Нужно было решить: как структурировать E2E тесты — через TS tools (spawnSync TS
|
||||
plugin) или напрямую через Python `src.memory` + ripgrep subprocess.
|
||||
|
||||
## Решение
|
||||
|
||||
Тесты вызывают Python `src.memory` (search/index) и ripgrep напрямую через
|
||||
`subprocess.run` из Python тестов, НЕ через TS tools. Причины:
|
||||
|
||||
1. **Изоляция** — тесты проверяют Python backend + ripgrep отдельно от TS
|
||||
обёртки. TS tool — тонкий слой spawnSync, его логика (scoring, merge) уже
|
||||
покрыта unit-тестами плагина.
|
||||
2. **Простота env** — Python subprocess принимает `env` dict напрямую, можно
|
||||
удалить `OPENAI_BASE_URL` (сценарий 1) или подменить `OPENAI_API_KEY` (сценарий
|
||||
3) без манипуляций с process.env в TS.
|
||||
3. **Скорость** — 4 теста прошли за 34с с реальным OpenRouter. TS tool добавил
|
||||
бы накладные расходы на import/compile.
|
||||
4. **Переиспользование паттерна** — `test_embedder_live.py` уже использует
|
||||
`@pytest.mark.skipif(not RUN_LIVE)` — тот же маркер на module level.
|
||||
|
||||
Каждый сценарий создаёт tmp memoryDir через `tmp_path` fixture, пишет .md с
|
||||
уникальным термином `zzuniqtestterm42`, вызывает `_reindex` (poll `reindex.log`)
|
||||
и `_semantic_search`/`_rg_search`, сравнивает результаты.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **TS tool через subprocess** (`npx opencode-tool memory-search`) — отклонено:
|
||||
сложно передать кастомный env, медленнее, тестирует TS обёртку а не backend.
|
||||
- **pytest + import src.memory in-process** — отклонено: `embed_texts` читает
|
||||
`os.environ` при вызове (lazy после PR #100), но monkeypatch на module-level
|
||||
ломает параллельные тесты. Subprocess даёт полную изоляцию env per-test.
|
||||
- **Mock OpenRouter через httpx mock** — отклонено: цель E2E = реальный API,
|
||||
мок = unit-тест (уже есть `test_embedder.py`).
|
||||
58
docs/handoff/pr-102-e2e-memory-tests.md
Normal file
58
docs/handoff/pr-102-e2e-memory-tests.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
pr: 102
|
||||
title: E2E coverage for 4 hybrid-memory scenarios
|
||||
---
|
||||
|
||||
## Что сделано
|
||||
|
||||
Создан `tests/test_memory_tools_e2e.py` с 4 E2E сценариями, покрывающими гибридную
|
||||
память (keyword + semantic):
|
||||
|
||||
1. **zero-config keyword-only** — поиск работает без `OPENAI_BASE_URL` (semantic
|
||||
возвращает `[]`), keyword через ripgrep находит файл.
|
||||
2. **full hybrid** — semantic находит по точному термину и синониму (через
|
||||
embedding), keyword находит по точному слову.
|
||||
3. **fallback при падении OpenRouter** — при невалидном `OPENAI_API_KEY` semantic
|
||||
возвращает `[]` (401 не retryable → embedder returns None), keyword работает.
|
||||
4. **удаление файла** — после удаления .md + reindex, ни semantic, ни keyword не
|
||||
находят удалённый файл.
|
||||
|
||||
Все тесты маркированы `@pytest.mark.skipif(not RUN_LIVE)` — запускаются только с
|
||||
`RUN_LIVE=1`. Без env — 4 skipped.
|
||||
|
||||
Вспомогательные функции:
|
||||
- `_rg_search(pattern, dir)` — keyword через ripgrep (subprocess), fallback на
|
||||
`/root/.cache/opencode/bin/rg` если нет в PATH.
|
||||
- `_semantic_search(query, index_dir, env)` — semantic через `python3 -m
|
||||
src.memory search --json` (subprocess), с передачей кастомного env.
|
||||
- `_reindex(memory_dir, index_dir)` — reindex + poll `reindex.log` до `done:`.
|
||||
- `_make_memory_file(dir, term, synonym)` — tmp .md с frontmatter + уникальным
|
||||
термином `zzuniqtestterm42` + опциональным синонимом.
|
||||
|
||||
## Почему
|
||||
|
||||
Фаза 1 (PR #100) сделала Python backend safe (lazy-init, return None). Фаза 2
|
||||
(PR #101) создала 5 TS tools. Нужна E2E гарантия что keyword fallback работает
|
||||
когда semantic недоступен — без E2E регрессии: lazy-init поломался → все tools
|
||||
падают; fallback сломался → semantic-up → memory-down.
|
||||
|
||||
Подробное обоснование в `technical/memory-keyword-vs-semantic-decision.md` —
|
||||
гибрид выбран вместо semantic-only именно из-за стабильности keyword path.
|
||||
|
||||
## Pending
|
||||
|
||||
—
|
||||
|
||||
## Watch out
|
||||
|
||||
- Тесты требуют `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `OPENAI_EMBEDDING_MODEL` в env
|
||||
(реальный OpenRouter, не mock). Запуск: `RUN_LIVE=1 uv run pytest
|
||||
tests/test_memory_tools_e2e.py -v --no-cov`.
|
||||
- Сценарий 4 (deletion) требует dummy-файл в memory_dir — иначе `run_index`
|
||||
возвращает по `if not md_files` без обновления `index.json`, и удалённая запись
|
||||
остаётся в индексе. Добавлен `_make_dummy_file`.
|
||||
- `rg` может быть не в PATH — `_rg_bin()` пробует `shutil.which` затем
|
||||
`/root/.cache/opencode/bin/rg`, pytest.skip если нет.
|
||||
- Timeout: сценарий 3 — 300с (хотя 401 не retryable и embedder returns None
|
||||
быстро, на всякий случай), сценарии 2/4 — 180с.
|
||||
- Ruff: `subprocess.run` требует `check=False` (PLW1510) — добавлено явно.
|
||||
|
|
@ -99,6 +99,7 @@ opencode-config/
|
|||
│ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size<overlap)
|
||||
│ ├── test_memory_setup_tool.py # .opencode/tools/memory-setup.ts (via _ts_loader.mjs) — PR#36
|
||||
│ ├── test_memory_setup_tool.ts # TS wrapper test (mjs loader) — PR#36
|
||||
│ ├── test_memory_tools_e2e.py # E2E hybrid memory (4 scenarios: zero-config keyword-only, full hybrid, OpenRouter fallback, deletion; @pytest.mark.skipif(not RUN_LIVE); subprocess-based: ripgrep + python3 -m src.memory) — PR#102
|
||||
│ ├── test_merge_pr_tool.py # .opencode/tools/merge-pr.ts (via _ts_loader.mjs; 8 tests: base + repo explicit/omitted/invalid) — PR#65
|
||||
│ ├── test_merge_pr_tool.ts # TS wrapper test (mjs loader; 5 tests: base + repo) — PR#65
|
||||
│ ├── test_observability.py # .opencode/scripts/observability.py
|
||||
|
|
|
|||
234
tests/test_memory_tools_e2e.py
Normal file
234
tests/test_memory_tools_e2e.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("RUN_LIVE"),
|
||||
reason="requires real OpenRouter API",
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
UNIQUE_TERM = "zzuniqtestterm42"
|
||||
SYNONYM = "unique test concept"
|
||||
|
||||
|
||||
def _rg_bin() -> str:
|
||||
rg = shutil.which("rg") or "/root/.cache/opencode/bin/rg"
|
||||
if not Path(rg).exists():
|
||||
pytest.skip("ripgrep not available")
|
||||
return rg
|
||||
|
||||
|
||||
def _rg_search(pattern: str, memory_dir: Path) -> list[str]:
|
||||
rg = _rg_bin()
|
||||
result = subprocess.run(
|
||||
[
|
||||
rg,
|
||||
"-il",
|
||||
"--glob",
|
||||
"*.md",
|
||||
"--glob",
|
||||
"!.git",
|
||||
"--glob",
|
||||
"!.rag",
|
||||
"-e",
|
||||
pattern,
|
||||
str(memory_dir),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _semantic_search(
|
||||
query: str, index_dir: Path, env: dict[str, str] | None = None, timeout: int = 120
|
||||
) -> list[dict]:
|
||||
run_env = env if env is not None else dict(os.environ)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
"-m",
|
||||
"src.memory",
|
||||
"search",
|
||||
query,
|
||||
"-i",
|
||||
str(index_dir),
|
||||
"-k",
|
||||
"15",
|
||||
"--json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(REPO_ROOT),
|
||||
env=run_env,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
|
||||
|
||||
def _reindex(
|
||||
memory_dir: Path, index_dir: Path, env: dict[str, str] | None = None, timeout: int = 180
|
||||
) -> None:
|
||||
index_dir.mkdir(parents=True, exist_ok=True)
|
||||
run_env = env if env is not None else dict(os.environ)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"python3",
|
||||
"-m",
|
||||
"src.memory",
|
||||
"index",
|
||||
str(memory_dir),
|
||||
"-o",
|
||||
str(index_dir),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
cwd=str(REPO_ROOT),
|
||||
env=run_env,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"reindex failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
log_path = index_dir / "reindex.log"
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
if log_path.exists():
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
if "done:" in content or "failed:" in content:
|
||||
return
|
||||
time.sleep(1)
|
||||
pytest.fail("reindex did not write done/failed to log in time")
|
||||
|
||||
|
||||
def _make_memory_file(memory_dir: Path, term: str, synonym: str = "") -> Path:
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
tech_dir = memory_dir / "technical"
|
||||
tech_dir.mkdir(parents=True, exist_ok=True)
|
||||
fname = tech_dir / f"e2e-{uid}.md"
|
||||
body = (
|
||||
f"---\ntitle: E2E test {uid}\ntags: [e2e, test]\n"
|
||||
f"summary: E2E coverage probe\n---\n\n# E2E test\n\n"
|
||||
f"Contains {term} as exact marker. {synonym}\n"
|
||||
)
|
||||
fname.write_text(body, encoding="utf-8")
|
||||
return fname
|
||||
|
||||
|
||||
def _make_dummy_file(memory_dir: Path) -> Path:
|
||||
tech_dir = memory_dir / "technical"
|
||||
tech_dir.mkdir(parents=True, exist_ok=True)
|
||||
fname = tech_dir / "dummy.md"
|
||||
fname.write_text(
|
||||
"---\ntitle: dummy\n---\n\n# Dummy\n\nfiller content for reindex stability\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return fname
|
||||
|
||||
|
||||
def test_zero_config_keyword_only(tmp_path: Path) -> None:
|
||||
"""Scenario 1: keyword works without OPENAI_BASE_URL, semantic returns []."""
|
||||
memory_dir = tmp_path / "memory"
|
||||
index_dir = memory_dir / ".rag"
|
||||
fname = _make_memory_file(memory_dir, UNIQUE_TERM)
|
||||
rel_path = fname.relative_to(memory_dir).as_posix()
|
||||
|
||||
env_no_base = {k: v for k, v in os.environ.items() if k != "OPENAI_BASE_URL"}
|
||||
|
||||
semantic = _semantic_search(UNIQUE_TERM, index_dir, env=env_no_base)
|
||||
keyword = _rg_search(UNIQUE_TERM, memory_dir)
|
||||
|
||||
assert semantic == [], f"semantic should return [] without OPENAI_BASE_URL, got {semantic}"
|
||||
assert any(rel_path in p for p in keyword), f"keyword should find {rel_path}, got {keyword}"
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_full_hybrid_semantic_and_keyword(tmp_path: Path) -> None:
|
||||
"""Scenario 2: semantic finds by synonym, keyword finds by exact term."""
|
||||
memory_dir = tmp_path / "memory"
|
||||
index_dir = memory_dir / ".rag"
|
||||
fname = _make_memory_file(memory_dir, UNIQUE_TERM, SYNONYM)
|
||||
_make_dummy_file(memory_dir)
|
||||
rel_path = fname.relative_to(memory_dir).as_posix()
|
||||
|
||||
_reindex(memory_dir, index_dir)
|
||||
|
||||
semantic_term = _semantic_search(UNIQUE_TERM, index_dir)
|
||||
semantic_synonym = _semantic_search(SYNONYM, index_dir)
|
||||
keyword = _rg_search(UNIQUE_TERM, memory_dir)
|
||||
|
||||
sem_sources_term = {r["source"] for r in semantic_term}
|
||||
sem_sources_syn = {r["source"] for r in semantic_synonym}
|
||||
assert rel_path in sem_sources_term, (
|
||||
f"semantic should find by exact term, got {sem_sources_term}"
|
||||
)
|
||||
assert rel_path in sem_sources_syn, f"semantic should find by synonym, got {sem_sources_syn}"
|
||||
assert any(rel_path in p for p in keyword), f"keyword should find {rel_path}, got {keyword}"
|
||||
|
||||
|
||||
@pytest.mark.timeout(300)
|
||||
def test_fallback_openrouter_down(tmp_path: Path) -> None:
|
||||
"""Scenario 3: keyword works when OpenRouter returns 401."""
|
||||
memory_dir = tmp_path / "memory"
|
||||
index_dir = memory_dir / ".rag"
|
||||
fname = _make_memory_file(memory_dir, UNIQUE_TERM, SYNONYM)
|
||||
_make_dummy_file(memory_dir)
|
||||
rel_path = fname.relative_to(memory_dir).as_posix()
|
||||
|
||||
_reindex(memory_dir, index_dir)
|
||||
|
||||
env_invalid_key = dict(os.environ)
|
||||
env_invalid_key["OPENAI_API_KEY"] = "invalid_key"
|
||||
|
||||
semantic = _semantic_search(UNIQUE_TERM, index_dir, env=env_invalid_key)
|
||||
keyword = _rg_search(UNIQUE_TERM, memory_dir)
|
||||
|
||||
assert semantic == [], f"semantic should return [] with invalid key, got {semantic}"
|
||||
assert any(rel_path in p for p in keyword), (
|
||||
f"keyword fallback should find {rel_path}, got {keyword}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(180)
|
||||
def test_file_deletion(tmp_path: Path) -> None:
|
||||
"""Scenario 4: after deletion + reindex, search returns []."""
|
||||
memory_dir = tmp_path / "memory"
|
||||
index_dir = memory_dir / ".rag"
|
||||
fname = _make_memory_file(memory_dir, UNIQUE_TERM, SYNONYM)
|
||||
_make_dummy_file(memory_dir)
|
||||
rel_path = fname.relative_to(memory_dir).as_posix()
|
||||
|
||||
_reindex(memory_dir, index_dir)
|
||||
|
||||
fname.unlink()
|
||||
_reindex(memory_dir, index_dir)
|
||||
|
||||
semantic = _semantic_search(UNIQUE_TERM, index_dir)
|
||||
keyword = _rg_search(UNIQUE_TERM, memory_dir)
|
||||
|
||||
sem_sources = {r["source"] for r in semantic}
|
||||
assert rel_path not in sem_sources, (
|
||||
f"deleted file should not be in semantic results, got {sem_sources}"
|
||||
)
|
||||
assert not any(rel_path in p for p in keyword), (
|
||||
f"deleted file should not be in keyword results, got {keyword}"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue