kin bootstrap <path> --id <id> --name <name> [--vault <path>] Detects: package.json, requirements.txt, go.mod, config files → tech_stack. Scans src/app/lib/frontend/backend dirs → modules with type detection. Parses CLAUDE.md for GOTCHA/WORKAROUND/FIXME/ВАЖНО → decisions. Scans Obsidian vault for kanban tasks, checkboxes, and decisions. Preview before save, -y to skip confirmation. 18 bootstrap tests, 57 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
324 lines
10 KiB
Python
324 lines
10 KiB
Python
"""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("<template></template>")
|
||
(src / "components" / "search" / "SearchFilter.vue").write_text("<template></template>")
|
||
(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("<template></template>")
|
||
|
||
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
|