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:
Sergey 2026-07-27 01:32:53 +03:00 committed by GitHub
parent 45bbdc50f3
commit 9d1373a51b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 338 additions and 0 deletions

View 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`).

View 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) — добавлено явно.

View file

@ -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

View 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}"
)