"""Tests for agents/bootstrap.py — tech detection, modules, decisions, obsidian.""" import json import pytest from pathlib import Path from click.testing import CliRunner from agents.bootstrap import ( detect_tech_stack, detect_modules, extract_decisions_from_claude_md, find_vault_root, scan_obsidian, format_preview, save_to_db, ) from core.db import init_db from core import models from cli.main import cli # --------------------------------------------------------------------------- # Tech stack detection # --------------------------------------------------------------------------- def test_detect_node_project(tmp_path): (tmp_path / "package.json").write_text(json.dumps({ "dependencies": {"vue": "^3.4", "pinia": "^2.0"}, "devDependencies": {"typescript": "^5.0", "vite": "^5.0"}, })) (tmp_path / "tsconfig.json").write_text("{}") (tmp_path / "nuxt.config.ts").write_text("export default {}") stack = detect_tech_stack(tmp_path) assert "vue3" in stack assert "typescript" in stack assert "nuxt3" in stack assert "pinia" in stack assert "vite" in stack def test_detect_python_project(tmp_path): (tmp_path / "requirements.txt").write_text("fastapi==0.104\npydantic>=2.0\n") (tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n") stack = detect_tech_stack(tmp_path) assert "python" in stack assert "fastapi" in stack assert "pydantic" in stack def test_detect_go_project(tmp_path): (tmp_path / "go.mod").write_text("module example.com/foo\nrequire gin-gonic v1.9\n") stack = detect_tech_stack(tmp_path) assert "go" in stack assert "gin" in stack def test_detect_monorepo(tmp_path): fe = tmp_path / "frontend" fe.mkdir() (fe / "package.json").write_text(json.dumps({ "dependencies": {"vue": "^3.0"}, })) be = tmp_path / "backend" be.mkdir() (be / "requirements.txt").write_text("fastapi\n") stack = detect_tech_stack(tmp_path) assert "vue3" in stack assert "fastapi" in stack def test_detect_empty_dir(tmp_path): assert detect_tech_stack(tmp_path) == [] # --------------------------------------------------------------------------- # Module detection # --------------------------------------------------------------------------- def test_detect_modules_vue(tmp_path): src = tmp_path / "src" (src / "components" / "search").mkdir(parents=True) (src / "components" / "search" / "Search.vue").write_text("") (src / "components" / "search" / "SearchFilter.vue").write_text("") (src / "api" / "auth").mkdir(parents=True) (src / "api" / "auth" / "login.ts").write_text("import express from 'express';\nconst router = express.Router();") modules = detect_modules(tmp_path) names = {m["name"] for m in modules} assert "components" in names or "search" in names assert "api" in names or "auth" in names def test_detect_modules_empty(tmp_path): assert detect_modules(tmp_path) == [] def test_detect_modules_backend_pg(tmp_path): """Test detection in backend-pg/src/ pattern (like vdolipoperek).""" src = tmp_path / "backend-pg" / "src" / "services" src.mkdir(parents=True) (src / "tourMapper.js").write_text("const express = require('express');") (src / "dbService.js").write_text("module.exports = { query };") modules = detect_modules(tmp_path) assert any(m["name"] == "services" for m in modules) # --------------------------------------------------------------------------- # Decisions from CLAUDE.md # --------------------------------------------------------------------------- def test_extract_decisions(tmp_path): (tmp_path / "CLAUDE.md").write_text("""# Project ## Rules - Use WAL mode for SQLite ВАЖНО: docker-compose v1 глючит → только raw docker commands WORKAROUND: position:fixed breaks on iOS Safari, use transform instead GOTCHA: Sletat API бан при параллельных запросах FIXME: race condition in useSearch composable ## Known Issues - Mobile bottom-sheet не работает в landscape mode - CSS grid fallback для IE11 (но мы его не поддерживаем) """) decisions = extract_decisions_from_claude_md(tmp_path) assert len(decisions) >= 4 types = {d["type"] for d in decisions} assert "gotcha" in types assert "workaround" in types def test_extract_decisions_no_claude_md(tmp_path): assert extract_decisions_from_claude_md(tmp_path) == [] # --------------------------------------------------------------------------- # Obsidian vault # --------------------------------------------------------------------------- def test_scan_obsidian(tmp_path): # Create a mock vault vault = tmp_path / "vault" proj_dir = vault / "myproject" proj_dir.mkdir(parents=True) (proj_dir / "kanban.md").write_text("""--- kanban-plugin: board --- ## В работе - [ ] Fix login page - [ ] Add search filter - [x] Setup CI/CD ## Done - [x] Initial deploy **ВАЖНО:** Не забыть обновить SSL сертификат """) (proj_dir / "notes.md").write_text("""# Notes GOTCHA: API rate limit is 10 req/s - [ ] Write tests for auth module """) result = scan_obsidian(vault, "myproject", "My Project", "myproject") assert result["files_scanned"] == 2 assert len(result["tasks"]) >= 4 # 3 pending + at least 1 done assert len(result["decisions"]) >= 1 # At least the ВАЖНО one pending = [t for t in result["tasks"] if not t["done"]] done = [t for t in result["tasks"] if t["done"]] assert len(pending) >= 3 assert len(done) >= 1 def test_scan_obsidian_no_match(tmp_path): vault = tmp_path / "vault" vault.mkdir() (vault / "other.md").write_text("# Unrelated note\nSomething else.") result = scan_obsidian(vault, "myproject", "My Project") assert result["files_scanned"] == 0 assert result["tasks"] == [] def test_find_vault_root_explicit(tmp_path): vault = tmp_path / "vault" vault.mkdir() assert find_vault_root(vault) == vault def test_find_vault_root_none(): assert find_vault_root(Path("/nonexistent/path")) is None # --------------------------------------------------------------------------- # Save to DB # --------------------------------------------------------------------------- def test_save_to_db(tmp_path): conn = init_db(":memory:") save_to_db( conn, project_id="test", name="Test Project", path=str(tmp_path), tech_stack=["python", "fastapi"], modules=[ {"name": "api", "type": "backend", "path": "src/api/", "file_count": 5}, {"name": "ui", "type": "frontend", "path": "src/ui/", "file_count": 8}, ], decisions=[ {"type": "gotcha", "title": "Bug X", "description": "desc", "category": "ui"}, ], obsidian={ "tasks": [ {"title": "Fix login", "done": False, "source": "kanban"}, {"title": "Setup CI", "done": True, "source": "kanban"}, ], "decisions": [ {"type": "gotcha", "title": "API limit", "description": "10 req/s", "category": "api", "source": "notes"}, ], "files_scanned": 2, }, ) p = models.get_project(conn, "test") assert p is not None assert p["tech_stack"] == ["python", "fastapi"] mods = models.get_modules(conn, "test") assert len(mods) == 2 decs = models.get_decisions(conn, "test") assert len(decs) == 2 # 1 from CLAUDE.md + 1 from Obsidian tasks = models.list_tasks(conn, project_id="test") assert len(tasks) == 2 # 2 from Obsidian assert any(t["status"] == "done" for t in tasks) assert any(t["status"] == "pending" for t in tasks) conn.close() # --------------------------------------------------------------------------- # format_preview # --------------------------------------------------------------------------- def test_format_preview(): text = format_preview( "vdol", "ВДОЛЬ", "~/projects/vdol", ["vue3", "typescript"], [{"name": "search", "type": "frontend", "path": "src/search/", "file_count": 4}], [{"type": "gotcha", "title": "Safari bug"}], {"files_scanned": 3, "tasks": [ {"title": "Fix X", "done": False, "source": "kb"}, ], "decisions": []}, ) assert "vue3" in text assert "search" in text assert "Safari bug" in text assert "Fix X" in text # --------------------------------------------------------------------------- # CLI integration # --------------------------------------------------------------------------- def test_cli_bootstrap(tmp_path): # Create a minimal project to bootstrap proj = tmp_path / "myproj" proj.mkdir() (proj / "package.json").write_text(json.dumps({ "dependencies": {"vue": "^3.0"}, })) src = proj / "src" / "components" src.mkdir(parents=True) (src / "App.vue").write_text("") db_path = tmp_path / "test.db" runner = CliRunner() result = runner.invoke(cli, [ "--db", str(db_path), "bootstrap", str(proj), "--id", "myproj", "--name", "My Project", "--vault", str(tmp_path / "nonexistent_vault"), "-y", ]) assert result.exit_code == 0 assert "vue3" in result.output assert "Saved:" in result.output # Verify in DB conn = init_db(db_path) p = models.get_project(conn, "myproj") assert p is not None assert "vue3" in p["tech_stack"] conn.close() def test_cli_bootstrap_already_exists(tmp_path): proj = tmp_path / "myproj" proj.mkdir() db_path = tmp_path / "test.db" runner = CliRunner() # Create project first runner.invoke(cli, ["--db", str(db_path), "project", "add", "myproj", "X", str(proj)]) # Try bootstrap — should fail result = runner.invoke(cli, [ "--db", str(db_path), "bootstrap", str(proj), "--id", "myproj", "--name", "X", "-y", ]) assert result.exit_code == 1 assert "already exists" in result.output