418 lines
13 KiB
Python
418 lines
13 KiB
Python
"""Tests for the CLI init bootstrap handler.
|
|
|
|
Covers _sanitize_name() and init_project() — all file operations
|
|
use tmp_path to stay fully isolated from the live filesystem.
|
|
"""
|
|
|
|
import json
|
|
import uuid
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from aipass.cli.apps.handlers.init.bootstrap import _sanitize_name, init_project
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _sanitize_name tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_sanitize_name_normal_input():
|
|
"""Normal lowercase string is uppercased."""
|
|
assert _sanitize_name("my_project") == "MY_PROJECT"
|
|
|
|
|
|
def test_sanitize_name_preserves_hyphens():
|
|
"""Hyphens are kept as-is (valid filename chars)."""
|
|
assert _sanitize_name("my-project") == "MY-PROJECT"
|
|
|
|
|
|
def test_sanitize_name_replaces_special_chars():
|
|
"""Non-alphanumeric characters (except _ and -) become underscores."""
|
|
assert _sanitize_name("my project!v2") == "MY_PROJECT_V2"
|
|
|
|
|
|
def test_sanitize_name_replaces_dots_and_slashes():
|
|
"""Dots and slashes are replaced with underscores."""
|
|
assert _sanitize_name("foo.bar/baz") == "FOO_BAR_BAZ"
|
|
|
|
|
|
def test_sanitize_name_strips_leading_trailing_underscores():
|
|
"""Leading/trailing underscores from replacement are stripped."""
|
|
assert _sanitize_name("...name...") == "NAME"
|
|
|
|
|
|
def test_sanitize_name_spaces_become_underscores():
|
|
"""Spaces are not alphanumeric, so they become underscores."""
|
|
assert _sanitize_name("hello world") == "HELLO_WORLD"
|
|
|
|
|
|
def test_sanitize_name_empty_after_sanitize():
|
|
"""All-special-character input collapses to empty string."""
|
|
assert _sanitize_name("!!!") == ""
|
|
|
|
|
|
def test_sanitize_name_already_upper():
|
|
"""Already-uppercase names pass through unchanged."""
|
|
assert _sanitize_name("ALPHA") == "ALPHA"
|
|
|
|
|
|
def test_sanitize_name_empty_string():
|
|
"""Empty input returns empty string."""
|
|
assert _sanitize_name("") == ""
|
|
|
|
|
|
def test_sanitize_name_only_underscores():
|
|
"""All-underscore input is stripped to empty string."""
|
|
assert _sanitize_name("___") == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# init_project tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_init_project_creates_all_expected_files(tmp_path):
|
|
"""init_project produces all expected files and directories."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
result = init_project(target, project_name="demo")
|
|
|
|
expected_files = [
|
|
target / "DEMO_REGISTRY.json",
|
|
target / ".aipass" / "aipass_global_prompt.md",
|
|
target / "CLAUDE.md",
|
|
target / "AGENTS.md",
|
|
target / "GEMINI.md",
|
|
target / "README.md",
|
|
target / "STATUS.local.md",
|
|
target / ".gitignore",
|
|
target / ".claude" / "settings.json",
|
|
]
|
|
for f in expected_files:
|
|
assert f.exists(), f"Expected file not created: {f}"
|
|
|
|
# hooks/ and src/ are directories, not files
|
|
assert (target / "hooks").is_dir(), "Expected hooks/ directory"
|
|
assert (target / "src").is_dir(), "Expected src/ directory"
|
|
|
|
# No .trinity/ should be created (projects are not citizens)
|
|
assert not (target / ".trinity").exists(), ".trinity/ should NOT be created"
|
|
|
|
# No local prompt at project level (belongs in agent dirs only)
|
|
assert not (target / ".aipass" / "aipass_local_prompt.md").exists()
|
|
|
|
# 9 files + 2 directories = 11 created_files entries
|
|
assert len(result["created_files"]) == 11
|
|
|
|
|
|
def test_init_project_return_dict_structure(tmp_path):
|
|
"""Return dict contains all required keys with correct types."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
result = init_project(target, project_name="alpha")
|
|
|
|
assert set(result.keys()) == {
|
|
"registry_id",
|
|
"registry_file",
|
|
"project_name",
|
|
"target",
|
|
"created_files",
|
|
}
|
|
assert result["project_name"] == "ALPHA"
|
|
assert result["registry_file"] == "ALPHA_REGISTRY.json"
|
|
assert result["target"] == str(target.resolve())
|
|
assert isinstance(result["created_files"], list)
|
|
|
|
|
|
def test_init_project_registry_id_is_valid_uuid(tmp_path):
|
|
"""registry_id must be a valid UUID4 string."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
result = init_project(target, project_name="test")
|
|
|
|
parsed = uuid.UUID(result["registry_id"], version=4)
|
|
assert str(parsed) == result["registry_id"]
|
|
|
|
|
|
def test_init_project_registry_json_contents(tmp_path):
|
|
"""REGISTRY.json has correct metadata structure and values."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
result = init_project(target, project_name="beta")
|
|
|
|
registry_path = target / "BETA_REGISTRY.json"
|
|
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
|
|
assert data["metadata"]["id"] == result["registry_id"]
|
|
assert data["metadata"]["name"] == "BETA"
|
|
assert data["metadata"]["version"] == "1.0.0"
|
|
assert data["metadata"]["created"] == date.today().isoformat()
|
|
assert data["metadata"]["last_updated"] == date.today().isoformat()
|
|
assert data["metadata"]["total_branches"] == 0
|
|
assert data["branches"] == []
|
|
|
|
|
|
def test_init_project_no_trinity_created(tmp_path):
|
|
"""Projects are not citizens — no .trinity/ directory created."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="gamma")
|
|
|
|
assert not (target / ".trinity").exists()
|
|
|
|
|
|
def test_init_project_no_local_prompt(tmp_path):
|
|
"""Project init does NOT create aipass_local_prompt.md (agent-only file)."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="epsilon")
|
|
|
|
assert not (target / ".aipass" / "aipass_local_prompt.md").exists()
|
|
|
|
|
|
def test_init_project_claude_md_content(tmp_path):
|
|
"""CLAUDE.md contains real AIPass project content."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="zeta")
|
|
|
|
md_path = target / "CLAUDE.md"
|
|
content = md_path.read_text(encoding="utf-8")
|
|
assert "# ZETA" in content
|
|
assert "## What is AIPass" in content
|
|
assert "## Getting Started" in content
|
|
assert "## Available Commands" in content
|
|
assert "## Startup Protocol" in content
|
|
# Startup protocol reads registry, not .trinity/
|
|
startup_idx = content.index("## Startup Protocol")
|
|
startup_section = content[startup_idx:]
|
|
assert ".trinity/" not in startup_section
|
|
assert "aipass init agent" in content
|
|
assert "ZETA_REGISTRY.json" in content
|
|
|
|
|
|
def test_init_project_rerunnable_skips_existing_registry(tmp_path):
|
|
"""Running init twice skips the existing registry and reuses its ID."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
result1 = init_project(target, project_name="dup")
|
|
result2 = init_project(target, project_name="dup")
|
|
|
|
# Same registry ID reused
|
|
assert result1["registry_id"] == result2["registry_id"]
|
|
# Second run creates no new files (all already exist)
|
|
assert len(result2["created_files"]) == 0
|
|
|
|
|
|
def test_init_project_raises_on_empty_name(tmp_path):
|
|
"""ValueError when the name is empty after sanitization."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
with pytest.raises(ValueError, match="Cannot derive project name"):
|
|
init_project(target, project_name="!!!")
|
|
|
|
|
|
def test_init_project_agents_md_content(tmp_path):
|
|
"""AGENTS.md contains Codex-equivalent content."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="alpha")
|
|
|
|
content = (target / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert "# ALPHA" in content
|
|
assert "ALPHA_REGISTRY.json" in content
|
|
assert "aipass init agent" in content
|
|
|
|
|
|
def test_init_project_gemini_md_content(tmp_path):
|
|
"""GEMINI.md contains Gemini-equivalent content."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="alpha")
|
|
|
|
content = (target / "GEMINI.md").read_text(encoding="utf-8")
|
|
assert "# ALPHA" in content
|
|
assert "ALPHA_REGISTRY.json" in content
|
|
|
|
|
|
def test_init_project_gitignore_content(tmp_path):
|
|
""".gitignore contains standard AIPass patterns."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="alpha")
|
|
|
|
content = (target / ".gitignore").read_text(encoding="utf-8")
|
|
assert ".trinity/" in content
|
|
assert ".ai_mail.local/" in content
|
|
assert "__pycache__/" in content
|
|
assert "logs/" in content
|
|
|
|
|
|
def test_init_project_claude_settings_content(tmp_path):
|
|
""".claude/settings.json has valid hook configuration."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="alpha")
|
|
|
|
settings_path = target / ".claude" / "settings.json"
|
|
data = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
assert "hooks" in data
|
|
assert "UserPromptSubmit" in data["hooks"]
|
|
|
|
|
|
def test_init_project_global_prompt_content(tmp_path):
|
|
"""Global prompt contains project name and AIPass terminology."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="alpha")
|
|
|
|
content = (target / ".aipass" / "aipass_global_prompt.md").read_text(encoding="utf-8")
|
|
assert "# ALPHA" in content
|
|
assert "ALPHA_REGISTRY.json" in content
|
|
assert "## Commands" in content
|
|
|
|
|
|
def test_init_project_readme_md_content(tmp_path):
|
|
"""README.md contains getting started guide with project name."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="alpha")
|
|
|
|
content = (target / "README.md").read_text(encoding="utf-8")
|
|
assert "# ALPHA" in content
|
|
assert "## Quick Start" in content
|
|
assert "aipass init agent" in content
|
|
assert "src/" in content
|
|
|
|
|
|
def test_init_project_auto_creates_target_dir(tmp_path):
|
|
"""Target directory is created (including parents) if it doesn't exist."""
|
|
target = tmp_path / "deep" / "nested" / "proj"
|
|
assert not target.exists()
|
|
|
|
result = init_project(target, project_name="nested")
|
|
|
|
assert target.is_dir()
|
|
assert result["project_name"] == "NESTED"
|
|
assert len(result["created_files"]) == 11
|
|
|
|
|
|
def test_init_project_defaults_name_from_directory(tmp_path):
|
|
"""When project_name is None, name is derived from the directory name."""
|
|
target = tmp_path / "my_cool_project"
|
|
target.mkdir()
|
|
|
|
result = init_project(target)
|
|
|
|
assert result["project_name"] == "MY_COOL_PROJECT"
|
|
assert result["registry_file"] == "MY_COOL_PROJECT_REGISTRY.json"
|
|
assert (target / "MY_COOL_PROJECT_REGISTRY.json").exists()
|
|
|
|
|
|
def test_init_project_custom_name_overrides_directory(tmp_path):
|
|
"""Explicit project_name takes precedence over the directory name."""
|
|
target = tmp_path / "dir_name"
|
|
target.mkdir()
|
|
|
|
result = init_project(target, project_name="custom")
|
|
|
|
assert result["project_name"] == "CUSTOM"
|
|
assert result["registry_file"] == "CUSTOM_REGISTRY.json"
|
|
|
|
|
|
def test_init_project_skips_existing_optional_files(tmp_path):
|
|
"""Optional files are not overwritten if they already exist.
|
|
Init is re-runnable — existing files are skipped."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
# Pre-create optional files
|
|
aipass_dir = target / ".aipass"
|
|
aipass_dir.mkdir()
|
|
(aipass_dir / "aipass_global_prompt.md").write_text(
|
|
"# Custom global\n", encoding="utf-8"
|
|
)
|
|
(target / "CLAUDE.md").write_text("# Custom CLAUDE\n", encoding="utf-8")
|
|
(target / "AGENTS.md").write_text("# Custom AGENTS\n", encoding="utf-8")
|
|
(target / "GEMINI.md").write_text("# Custom GEMINI\n", encoding="utf-8")
|
|
(target / "README.md").write_text("# Custom README\n", encoding="utf-8")
|
|
(target / "STATUS.local.md").write_text("# Custom status\n", encoding="utf-8")
|
|
(target / ".gitignore").write_text("# Custom\n", encoding="utf-8")
|
|
|
|
claude_dir = target / ".claude"
|
|
claude_dir.mkdir()
|
|
(claude_dir / "settings.json").write_text("{}\n", encoding="utf-8")
|
|
|
|
hooks_dir = target / "hooks"
|
|
hooks_dir.mkdir()
|
|
|
|
src_dir = target / "src"
|
|
src_dir.mkdir()
|
|
|
|
result = init_project(target, project_name="eta")
|
|
|
|
# Only registry should be in created_files (everything else pre-existed)
|
|
assert len(result["created_files"]) == 1
|
|
|
|
# Verify pre-existing files were NOT overwritten
|
|
md_content = (target / "CLAUDE.md").read_text(encoding="utf-8")
|
|
assert md_content == "# Custom CLAUDE\n"
|
|
|
|
|
|
def test_init_project_no_overwrite(tmp_path):
|
|
"""Init does not overwrite existing files — re-runnable safety."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
# First run creates files
|
|
result1 = init_project(target, project_name="safe")
|
|
assert len(result1["created_files"]) > 0
|
|
|
|
# Second run creates nothing — all files skipped
|
|
result2 = init_project(target, project_name="safe")
|
|
assert len(result2["created_files"]) == 0
|
|
|
|
# Content from first run is preserved
|
|
claude_md = (target / "CLAUDE.md").read_text(encoding="utf-8")
|
|
assert "SAFE" in claude_md
|
|
|
|
|
|
def test_init_project_returns_dict(tmp_path):
|
|
"""init_project return value is a dict."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
result = init_project(target, project_name="rtype")
|
|
|
|
assert isinstance(result, dict)
|
|
|
|
|
|
def test_init_project_agents_md_no_trinity(tmp_path):
|
|
"""AGENTS.md startup protocol references registry, not .trinity/."""
|
|
target = tmp_path / "proj"
|
|
target.mkdir()
|
|
|
|
init_project(target, project_name="keep")
|
|
|
|
content = (target / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert ".trinity/" not in content
|
|
assert "KEEP_REGISTRY.json" in content
|