kin/tests/test_bootstrap.py
johnfrum1234 da4a8aae72 Add bootstrap command — auto-detect project stack, modules, decisions
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>
2026-03-15 13:29:01 +02:00

324 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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