From 9d1373a51b7a055e3e83abe5172329df88e3b0ea Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:32:53 +0300 Subject: [PATCH] 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 --- docs/decisions/044-pr-102-e2e-memory-tests.md | 45 ++++ docs/handoff/pr-102-e2e-memory-tests.md | 58 +++++ docs/project-map/README.md | 1 + tests/test_memory_tools_e2e.py | 234 ++++++++++++++++++ 4 files changed, 338 insertions(+) create mode 100644 docs/decisions/044-pr-102-e2e-memory-tests.md create mode 100644 docs/handoff/pr-102-e2e-memory-tests.md create mode 100644 tests/test_memory_tools_e2e.py diff --git a/docs/decisions/044-pr-102-e2e-memory-tests.md b/docs/decisions/044-pr-102-e2e-memory-tests.md new file mode 100644 index 0000000..34d9c4e --- /dev/null +++ b/docs/decisions/044-pr-102-e2e-memory-tests.md @@ -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`). \ No newline at end of file diff --git a/docs/handoff/pr-102-e2e-memory-tests.md b/docs/handoff/pr-102-e2e-memory-tests.md new file mode 100644 index 0000000..cf39bd6 --- /dev/null +++ b/docs/handoff/pr-102-e2e-memory-tests.md @@ -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) — добавлено явно. \ No newline at end of file diff --git a/docs/project-map/README.md b/docs/project-map/README.md index 4350c3c..3a1f198 100644 --- a/docs/project-map/README.md +++ b/docs/project-map/README.md @@ -99,6 +99,7 @@ opencode-config/ │ ├── test_chunking.py # _chunk_text edge cases (empty, unicode, size 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}" + )