← Files AkinatorARCHIVED FILE
tests/test_installer.py
10.7 KB · Oct 3, 2026 · 06:33 UTC
"""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