← Files AkinatorARCHIVED FILE

tests/test_installer.py

10.7 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

"""Tests for the one installer - install.sh and its Windows twin install.ps1.

Every test runs the REAL installer against a throwaway home directory and a
stub `claude` that only records its arguments. Nothing here can touch the
machine's real Claude, Codex or Cursor configuration: AKINATOR_USER_HOME,
CODEX_HOME and AKINATOR_CLAUDE_BIN are always set.

What these protect, in the order they went wrong or could:

- **One entry per platform.** An install leaves exactly one Akinator skill -
  the old per-station folders from earlier versions are removed, or Codex and
  Cursor keep listing twenty-one of them.
- **True where it lands.** A repository install passes Akinator's own strict
  checker *in that repository* - the test that changes trees (rules/12).
- **Own only what is ours.** A user's skill, a user's AGENTS.md text, anything
  without the Akinator banner, survives install and uninstall untouched.
- **Reversible.** Uninstall restores a host repository byte for byte.
"""

from __future__ import annotations

import hashlib
import os
import shutil
import stat
import subprocess
import sys
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parent.parent
SH = shutil.which("sh")
POWERSHELL = shutil.which("powershell") if sys.platform == "win32" else None
COVERAGE = REPO / "skills" / "everything" / "scripts" / "akinator_coverage.py"

needs_sh = pytest.mark.skipif(SH is None, reason="no POSIX sh on this machine")
needs_ps = pytest.mark.skipif(POWERSHELL is None, reason="Windows PowerShell only")

OURS_OLD = ("---\nname: akinator-plan\ndescription: Use x\n---\n<!--\n"
            "Generated by `scripts/build_codex_pack.py` from `skills/x/SKILL.md`.\n-->\n")
THEIRS = "---\nname: akinator-notes\ndescription: Use for my own notes\n---\nmine\n"


def _posix(path: Path) -> str:
    return path.as_posix()


def _snapshot(root: Path) -> dict[str, str]:
    return {p.relative_to(root).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
            for p in sorted(root.rglob("*")) if p.is_file()}


@pytest.fixture
def world(tmp_path: Path) -> dict:
    """A fake home with Codex and Cursor present, an old Akinator install, a
    skill of the user's own, and a stub claude that logs every call."""
    home = tmp_path / "home"
    skills = home / ".agents" / "skills"
    (skills / "akinator-plan").mkdir(parents=True)
    (skills / "akinator-plan" / "SKILL.md").write_text(OURS_OLD, encoding="utf-8")
    (skills / "akinator-notes").mkdir()
    (skills / "akinator-notes" / "SKILL.md").write_text(THEIRS, encoding="utf-8")
    (home / ".codex").mkdir()
    (home / ".codex" / "AGENTS.md").write_text("# My Codex notes\n\nKeep me.\n",
                                               encoding="utf-8")
    (home / ".cursor").mkdir()

    log = tmp_path / "claude.log"
    stub_sh = tmp_path / "claude"
    stub_sh.write_text('#!/bin/sh\necho "claude $*" >> "$STUB_LOG"\nexit 0\n',
                       encoding="utf-8", newline="\n")
    stub_sh.chmod(stub_sh.stat().st_mode | stat.S_IEXEC)
    stub_cmd = tmp_path / "claude-stub.cmd"
    stub_cmd.write_text('@echo claude %* >> "%STUB_LOG%"\r\n', encoding="ascii")
    return {"tmp": tmp_path, "home": home, "log": log,
            "stub_sh": stub_sh, "stub_cmd": stub_cmd}


def _env(world: dict, stub: Path) -> dict:
    env = dict(os.environ)
    env.update({
        "AKINATOR_SOURCE": _posix(REPO),
        "AKINATOR_USER_HOME": _posix(world["home"]),
        "CODEX_HOME": _posix(world["home"] / ".codex"),
        "AKINATOR_CLAUDE_BIN": _posix(stub) if stub.suffix != ".cmd" else str(stub),
        "STUB_LOG": _posix(world["log"]) if stub.suffix != ".cmd" else str(world["log"]),
    })
    return env


def run_sh(world: dict, *args: str, stdin: str | None = None) -> subprocess.CompletedProcess:
    cmd = [SH, "-s", "--", *args] if stdin is not None else [SH, _posix(REPO / "install.sh"), *args]
    return subprocess.run(cmd, input=stdin, capture_output=True, text=True,
                          env=_env(world, world["stub_sh"]), timeout=120)


def run_ps(world: dict, *args: str) -> subprocess.CompletedProcess:
    cmd = [POWERSHELL, "-NoProfile", "-ExecutionPolicy", "Bypass",
           "-File", str(REPO / "install.ps1"), *args]
    return subprocess.run(cmd, capture_output=True, text=True,
                          env=_env(world, world["stub_cmd"]), timeout=120)


def _host_repo(root: Path) -> Path:
    (root / "src").mkdir(parents=True)
    (root / "README.md").write_text("# Host\n", encoding="utf-8")
    (root / "src" / "a.py").write_text("x = 1\n", encoding="utf-8")
    (root / "AGENTS.md").write_text("# Host agents\n\nUse tabs.\n", encoding="utf-8")
    return root


def _assert_user_install(world: dict) -> None:
    home = world["home"]
    skills = sorted(p.name for p in (home / ".agents" / "skills").iterdir())
    assert skills == ["akinator", "akinator-notes"], (
        "exactly one Akinator skill, the old per-station folder removed, and the "
        f"user's own skill kept - got {skills}")
    skill = (home / ".agents" / "skills" / "akinator" / "SKILL.md").read_text("utf-8")
    assert "\nname: akinator\n" in skill

    agents = (home / ".codex" / "AGENTS.md").read_text("utf-8")
    assert agents.startswith("# My Codex notes\n\nKeep me.\n"), "the user's text moved"
    assert agents.count("<!-- akinator:begin") == 1
    assert agents.count("<!-- akinator:end -->") == 1

    rule = (home / ".cursor" / "rules" / "akinator.mdc").read_text("utf-8")
    assert rule.startswith("---\n") and "alwaysApply: true" in rule


def _assert_repo_install(repo: Path) -> None:
    assert sorted(p.name for p in (repo / ".agents" / "skills").iterdir()) == ["akinator"]
    assert (repo / ".cursor" / "rules" / "akinator.mdc").is_file()
    agents = (repo / "AGENTS.md").read_text("utf-8")
    assert agents.startswith("# Host agents\n\nUse tabs.\n")
    assert agents.count("<!-- akinator:begin") == 1
    # The test that changes trees: Akinator's own strict checker, in the host.
    result = subprocess.run([sys.executable, str(COVERAGE), str(repo), "--strict"],
                            capture_output=True, text=True)
    assert result.returncode == 0, "installing Akinator dirtied the host:\n" + result.stdout


# --------------------------------------------------------------------------
# install.sh
# --------------------------------------------------------------------------

@needs_sh
def test_sh_user_install_gives_one_skill_everywhere(world: dict) -> None:
    result = run_sh(world)
    assert result.returncode == 0, result.stdout + result.stderr
    _assert_user_install(world)
    calls = world["log"].read_text("utf-8")
    assert "plugin marketplace add" in calls and "--scope user" in calls
    assert "plugin install akinator@akinator --scope user" in calls


@needs_sh
def test_sh_reinstall_is_idempotent(world: dict) -> None:
    assert run_sh(world).returncode == 0
    first = _snapshot(world["home"])
    assert run_sh(world).returncode == 0
    assert _snapshot(world["home"]) == first, "a reinstall changed the result"


@needs_sh
def test_sh_repo_install_leaves_the_host_clean_and_uninstall_restores_it(
    world: dict, tmp_path: Path
) -> None:
    repo = _host_repo(tmp_path / "host")
    before = _snapshot(repo)
    result = run_sh(world, "--repo", _posix(repo), "--codex", "--cursor")
    assert result.returncode == 0, result.stdout + result.stderr
    _assert_repo_install(repo)

    result = run_sh(world, "--repo", _posix(repo), "--codex", "--cursor", "--uninstall")
    assert result.returncode == 0, result.stdout + result.stderr
    assert _snapshot(repo) == before, "uninstall did not restore the host exactly"
    assert not (repo / ".agents").exists() and not (repo / ".cursor").exists(), (
        "uninstall left the empty directories it created")


@needs_sh
def test_sh_never_removes_what_it_does_not_own(world: dict) -> None:
    assert run_sh(world).returncode == 0
    assert run_sh(world, "--codex", "--cursor", "--uninstall").returncode == 0
    home = world["home"]
    assert (home / ".agents" / "skills" / "akinator-notes" / "SKILL.md").read_text("utf-8") == THEIRS
    assert not (home / ".agents" / "skills" / "akinator").exists()
    assert (home / ".codex" / "AGENTS.md").read_text("utf-8") == "# My Codex notes\n\nKeep me.\n"


@needs_sh
def test_sh_help_works_when_piped_like_curl(world: dict) -> None:
    """`curl ... | sh` has no script file to read help text from."""
    script = (REPO / "install.sh").read_text(encoding="utf-8")
    result = run_sh(world, "--help", stdin=script)
    assert result.returncode == 0
    assert "/akinator:everything" in result.stdout and "--repo" in result.stdout


@needs_sh
def test_sh_rejects_an_unknown_option(world: dict) -> None:
    result = run_sh(world, "--everything")
    assert result.returncode != 0
    assert "unknown option" in result.stderr


@needs_sh
def test_sh_warns_when_an_override_file_hides_the_block(world: dict) -> None:
    """Codex reads AGENTS.override.md INSTEAD of AGENTS.md when it is non-empty,
    so a block written only to AGENTS.md would silently do nothing."""
    (world["home"] / ".codex" / "AGENTS.override.md").write_text("override\n", encoding="utf-8")
    result = run_sh(world, "--codex")
    assert result.returncode == 0
    assert "AGENTS.override.md" in result.stderr


# --------------------------------------------------------------------------
# install.ps1 - the same behaviour, on Windows PowerShell
# --------------------------------------------------------------------------

@needs_ps
def test_ps_user_install_gives_one_skill_everywhere(world: dict) -> None:
    result = run_ps(world)
    assert result.returncode == 0, result.stdout + result.stderr
    _assert_user_install(world)
    assert "plugin install akinator@akinator --scope user" in world["log"].read_text("utf-8")


@needs_ps
def test_ps_repo_install_leaves_the_host_clean_and_uninstall_restores_it(
    world: dict, tmp_path: Path
) -> None:
    repo = _host_repo(tmp_path / "host")
    before = _snapshot(repo)
    result = run_ps(world, "-Repo", str(repo), "-Codex", "-Cursor")
    assert result.returncode == 0, result.stdout + result.stderr
    _assert_repo_install(repo)
    result = run_ps(world, "-Repo", str(repo), "-Codex", "-Cursor", "-Uninstall")
    assert result.returncode == 0, result.stdout + result.stderr
    assert _snapshot(repo) == before, "uninstall did not restore the host exactly"


@needs_ps
@needs_sh
def test_both_installers_write_the_same_block(world: dict, tmp_path: Path) -> None:
    """Two installers for one contract: they must not drift apart."""
    a = _host_repo(tmp_path / "a")
    b = _host_repo(tmp_path / "b")
    assert run_sh(world, "--repo", _posix(a), "--codex").returncode == 0
    assert run_ps(world, "-Repo", str(b), "-Codex").returncode == 0
    assert (a / "AGENTS.md").read_bytes() == (b / "AGENTS.md").read_bytes()

SHA-256: d1f6c1b688aae600c8b9eae3712ccbf7cef3706731ea727af019f9a5eaa1d790