* feat(memory): wire setup-memory.sh to Python CLI + JS wrapper * test(memory): add wrapper generation, idempotency, backup tests * docs(memory): update project map for setup-memory wrapper integration * docs(handoff): add handoff and ADR-033 for plugin wiring * docs(handoff): set PR number * docs(handoff): fix Pending em-dash typo in PR#77 handoff --------- Co-authored-by: opencode-agent <agent@opencode.local>
410 lines
16 KiB
Python
410 lines
16 KiB
Python
"""Tests for .opencode/scripts/setup-memory.sh — deterministic memory init.
|
|
|
|
Uses a local mock remote (``git init --bare``) instead of GitHub so the
|
|
tests are hermetic and offline-safe. ``tmp_path`` provides isolation.
|
|
|
|
The script is invoked via ``subprocess`` with env vars pointing at the
|
|
mock remote + a tmp dir. Each test asserts one of the deterministic
|
|
flow steps.
|
|
|
|
Script contract (``setup-memory.sh``):
|
|
1. mkdir -p MEMORY_DIR
|
|
2. clone REMOTE | git pull --ff-only
|
|
3. remote set-url if origin != REMOTE
|
|
4. install post-commit hook (auto-push) if missing/wrong
|
|
5. memory index if .rag missing (best-effort, CLI optional)
|
|
5b. generate JS wrapper for opencode-memory plugin (idempotent + backup)
|
|
6. echo status
|
|
|
|
Exit 1 when ``OPENCODE_MEMORY_REMOTE`` is unset (no default — the
|
|
``.env.example`` provides the value; absence is a config error).
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SCRIPT = REPO_ROOT / ".opencode" / "scripts" / "setup-memory.sh"
|
|
REPO_PYTHON = str(REPO_ROOT / ".venv" / "bin" / "python")
|
|
|
|
EXPECTED_HOOK = "#!/bin/bash\ngit push origin master 2>/dev/null || true\n"
|
|
|
|
|
|
def _seed_remote(remote_dir: Path) -> None:
|
|
"""Seed a bare remote with one commit on ``master``."""
|
|
remote_dir.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run(["git", "init", "--bare", "-q", str(remote_dir)], check=True)
|
|
seed = remote_dir.parent / "seed"
|
|
seed.mkdir()
|
|
subprocess.run(["git", "init", "-q", str(seed)], check=True)
|
|
(seed / "README.md").write_text("# memory\n")
|
|
subprocess.run(["git", "-C", str(seed), "add", "README.md"], check=True)
|
|
subprocess.run(
|
|
[
|
|
"git",
|
|
"-C",
|
|
str(seed),
|
|
"-c",
|
|
"user.email=t@t",
|
|
"-c",
|
|
"user.name=t",
|
|
"commit",
|
|
"-qm",
|
|
"init",
|
|
],
|
|
check=True,
|
|
)
|
|
subprocess.run(["git", "-C", str(seed), "branch", "-M", "master"], check=True)
|
|
subprocess.run(
|
|
["git", "-C", str(seed), "remote", "add", "origin", str(remote_dir)],
|
|
check=True,
|
|
)
|
|
subprocess.run(["git", "-C", str(seed), "push", "-q", "origin", "master"], check=True)
|
|
|
|
|
|
def _run_script(
|
|
memory_dir: Path,
|
|
remote: str | None,
|
|
wrapper: Path | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
"""Run setup-memory.sh with env pointing at tmp paths.
|
|
|
|
When ``remote`` is None, OPENCODE_MEMORY_REMOTE is removed from env
|
|
(simulating the "no remote env" error path). ``wrapper`` sets
|
|
``MEMORY_WRAPPER_PATH`` so tests don't write to the real plugin dir.
|
|
"""
|
|
env = {
|
|
**os.environ,
|
|
"OPENCODE_MEMORY_DIR": str(memory_dir),
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
}
|
|
if remote is not None:
|
|
env["OPENCODE_MEMORY_REMOTE"] = remote
|
|
else:
|
|
env.pop("OPENCODE_MEMORY_REMOTE", None)
|
|
if wrapper is not None:
|
|
env["MEMORY_WRAPPER_PATH"] = str(wrapper)
|
|
wrapper.parent.mkdir(parents=True, exist_ok=True)
|
|
env["MEMORY_WRAPPER_PYTHON"] = REPO_PYTHON
|
|
env["OPENCODE_WORKSPACE"] = str(REPO_ROOT)
|
|
return subprocess.run(
|
|
["bash", str(SCRIPT)],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
env=env,
|
|
)
|
|
|
|
|
|
def test_fresh_init(tmp_path: Path) -> None:
|
|
"""Empty dir → run → clone, hook, remote, files present."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
assert (mem / ".git").is_dir(), "repo not cloned"
|
|
assert (mem / "README.md").exists(), "file not pulled"
|
|
assert (mem / ".git" / "hooks" / "post-commit").exists(), "hook missing"
|
|
got = subprocess.run(
|
|
["git", "-C", str(mem), "remote", "get-url", "origin"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
assert got == str(remote), f"remote mismatch: {got}"
|
|
|
|
|
|
def test_existing_repo(tmp_path: Path) -> None:
|
|
""".git exists → run → pull --ff-only, no destructive changes."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
|
# capture state before second run
|
|
head_before = subprocess.run(
|
|
["git", "-C", str(mem), "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
head_after = subprocess.run(
|
|
["git", "-C", str(mem), "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
assert head_before == head_after, "pull --ff-only changed HEAD unexpectedly"
|
|
assert "pulling latest" in r.stdout
|
|
|
|
|
|
def test_wrong_remote(tmp_path: Path) -> None:
|
|
"""remote ≠ expected → run → set-url corrects the remote."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
|
# corrupt remote URL (tmp path — avoids S108 insecure-tmp warning)
|
|
wrong = tmp_path / "wrong-remote"
|
|
subprocess.run(
|
|
["git", "-C", str(mem), "remote", "set-url", "origin", str(wrong)],
|
|
check=True,
|
|
)
|
|
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
assert "fixing remote" in r.stdout
|
|
got = subprocess.run(
|
|
["git", "-C", str(mem), "remote", "get-url", "origin"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
assert got == str(remote), f"remote not fixed: {got}"
|
|
|
|
|
|
def test_missing_hook(tmp_path: Path) -> None:
|
|
"""hook missing → run → hook created with correct content."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
|
hook = mem / ".git" / "hooks" / "post-commit"
|
|
hook.unlink()
|
|
assert not hook.exists()
|
|
r = _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js")
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
assert hook.exists(), "hook not recreated"
|
|
assert hook.read_text() == EXPECTED_HOOK, f"hook content wrong: {hook.read_text()!r}"
|
|
assert "installing post-commit hook" in r.stdout
|
|
|
|
|
|
def test_idempotent(tmp_path: Path) -> None:
|
|
"""run 3x → state identical after run 1 and run 3."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
|
|
def _snapshot() -> dict[str, str | None]:
|
|
head = subprocess.run(
|
|
["git", "-C", str(mem), "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip()
|
|
hook = mem / ".git" / "hooks" / "post-commit"
|
|
return {
|
|
"head": head,
|
|
"hook": hook.read_text() if hook.exists() else None,
|
|
"remote": subprocess.run(
|
|
["git", "-C", str(mem), "remote", "get-url", "origin"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
).stdout.strip(),
|
|
}
|
|
|
|
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
|
snap1 = _snapshot()
|
|
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
|
assert _run_script(mem, str(remote), wrapper=tmp_path / "wrapper" / "rag.js").returncode == 0
|
|
snap3 = _snapshot()
|
|
assert snap1 == snap3, f"non-idempotent:\n run1={snap1}\n run3={snap3}"
|
|
|
|
|
|
def test_no_remote_env(tmp_path: Path) -> None:
|
|
"""OPENCODE_MEMORY_REMOTE unset → exit 1, error message to stderr/stdout."""
|
|
mem = tmp_path / "mem"
|
|
r = _run_script(mem, remote=None)
|
|
assert r.returncode == 1, f"expected exit 1, got {r.returncode}"
|
|
combined = r.stderr + r.stdout
|
|
assert "OPENCODE_MEMORY_REMOTE" in combined, f"missing error msg: {combined!r}"
|
|
assert "ERROR" in combined or "not set" in combined
|
|
|
|
|
|
# ── git insteadOf helper (GITHUB_TOKEN) ──────────────────────────────────────
|
|
|
|
|
|
def test_script_contains_git_insteadof() -> None:
|
|
"""setup-memory.sh contains `git config --global url.insteadOf` before clone."""
|
|
src = SCRIPT.read_text()
|
|
assert "git config --global url." in src, "missing git config --global url. call"
|
|
assert ".insteadOf https://github.com/" in src, "missing insteadOf directive"
|
|
# The insteadOf block must appear BEFORE the git clone line.
|
|
insteadof_pos = src.index(".insteadOf")
|
|
clone_pos = src.index("git clone --origin")
|
|
assert insteadof_pos < clone_pos, "insteadOf helper must precede git clone"
|
|
|
|
|
|
def test_script_contains_github_token_check() -> None:
|
|
"""setup-memory.sh checks GITHUB_TOKEN (guarded, not hard-fail if empty)."""
|
|
src = SCRIPT.read_text()
|
|
assert "GITHUB_TOKEN" in src, "missing GITHUB_TOKEN reference"
|
|
# Must be a conditional check (if [ -n ... ]), not an unconditional hard fail.
|
|
assert '-n "${GITHUB_TOKEN:-}"' in src or '-n "$GITHUB_TOKEN"' in src, (
|
|
"GITHUB_TOKEN must be a guarded check (if [ -n ... ]), not a hard requirement"
|
|
)
|
|
|
|
|
|
def test_insteadof_idempotent(tmp_path: Path) -> None:
|
|
"""Running the script N times does not duplicate the insteadOf entry.
|
|
|
|
`git config --global url.<prefix>.insteadOf` overwrites the value on each
|
|
call (idempotent by git semantics). We verify by invoking setup-memory.sh
|
|
twice with a fake GITHUB_TOKEN against a mock remote and checking that
|
|
`git config --global --get-all` returns exactly one match after each run.
|
|
"""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
|
|
# Use an isolated HOME so --global writes to a tmp gitconfig (no pollution).
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
|
|
env = {
|
|
**os.environ,
|
|
"OPENCODE_MEMORY_DIR": str(mem),
|
|
"OPENCODE_MEMORY_REMOTE": str(remote),
|
|
"GITHUB_TOKEN": "fake-token-for-test",
|
|
"HOME": str(home),
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"MEMORY_WRAPPER_PATH": str(tmp_path / "wrapper" / "rag.js"),
|
|
"MEMORY_WRAPPER_PYTHON": REPO_PYTHON,
|
|
"OPENCODE_WORKSPACE": str(REPO_ROOT),
|
|
}
|
|
(tmp_path / "wrapper").mkdir(exist_ok=True)
|
|
|
|
# First run: clone path triggers the insteadOf helper.
|
|
r1 = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env)
|
|
assert r1.returncode == 0, f"run1 failed: stdout={r1.stdout}\nstderr={r1.stderr}"
|
|
|
|
# Read the global gitconfig written by the script.
|
|
gitconfig = home / ".gitconfig"
|
|
assert gitconfig.exists(), f"gitconfig not created at {gitconfig}"
|
|
content1 = gitconfig.read_text()
|
|
assert "x-access-token:fake-token-for-test@github.com" in content1
|
|
assert "insteadOf" in content1
|
|
|
|
# Count insteadOf occurrences — should be exactly 1 (no duplication).
|
|
insteadof_count_1 = content1.count("insteadOf")
|
|
assert insteadof_count_1 == 1, f"expected 1 insteadOf after run1, got {insteadof_count_1}"
|
|
|
|
# Second run: existing repo → pull path (clone branch skipped). The helper
|
|
# is inside the clone branch, so it won't re-fire — but we assert the
|
|
# config is NOT duplicated regardless (single insteadOf entry preserved).
|
|
r2 = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env)
|
|
assert r2.returncode == 0, f"run2 failed: stdout={r2.stdout}\nstderr={r2.stderr}"
|
|
content2 = gitconfig.read_text()
|
|
insteadof_count_2 = content2.count("insteadOf")
|
|
assert insteadof_count_2 == 1, (
|
|
f"expected 1 insteadOf after run2, got {insteadof_count_2} (duplicate?)"
|
|
)
|
|
|
|
|
|
def test_no_github_token_skips_insteadof(tmp_path: Path) -> None:
|
|
"""When GITHUB_TOKEN is unset, the insteadOf helper is skipped (no error)."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
|
|
env = {
|
|
**os.environ,
|
|
"OPENCODE_MEMORY_DIR": str(mem),
|
|
"OPENCODE_MEMORY_REMOTE": str(remote),
|
|
"HOME": str(home),
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"MEMORY_WRAPPER_PATH": str(tmp_path / "wrapper" / "rag.js"),
|
|
"MEMORY_WRAPPER_PYTHON": REPO_PYTHON,
|
|
"OPENCODE_WORKSPACE": str(REPO_ROOT),
|
|
}
|
|
env.pop("GITHUB_TOKEN", None)
|
|
(tmp_path / "wrapper").mkdir(exist_ok=True)
|
|
|
|
r = subprocess.run(["bash", str(SCRIPT)], capture_output=True, text=True, check=False, env=env)
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
gitconfig = home / ".gitconfig"
|
|
if gitconfig.exists():
|
|
content = gitconfig.read_text()
|
|
assert "insteadOf" not in content, "insteadOf set without GITHUB_TOKEN"
|
|
|
|
|
|
# ── JS wrapper for opencode-memory plugin (step 5b) ──────────────────────────
|
|
|
|
|
|
def test_wrapper_generated(tmp_path: Path) -> None:
|
|
"""Run → wrapper file exists at MEMORY_WRAPPER_PATH with delegate content."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
wrapper = tmp_path / "wrapper" / "rag.js"
|
|
r = _run_script(mem, str(remote), wrapper=wrapper)
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
assert wrapper.exists(), f"wrapper not generated at {wrapper}"
|
|
assert wrapper.stat().st_mode & 0o100, "wrapper not executable"
|
|
assert "generated wrapper" in r.stdout, f"missing generate msg: {r.stdout!r}"
|
|
|
|
|
|
def test_wrapper_idempotent(tmp_path: Path) -> None:
|
|
"""Run 2x → wrapper content identical, 2nd run reports 'wrapper correct'."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
wrapper = tmp_path / "wrapper" / "rag.js"
|
|
r1 = _run_script(mem, str(remote), wrapper=wrapper)
|
|
assert r1.returncode == 0, f"run1: stdout={r1.stdout}\nstderr={r1.stderr}"
|
|
content1 = wrapper.read_text()
|
|
r2 = _run_script(mem, str(remote), wrapper=wrapper)
|
|
assert r2.returncode == 0, f"run2: stdout={r2.stdout}\nstderr={r2.stderr}"
|
|
content2 = wrapper.read_text()
|
|
assert content1 == content2, "wrapper content changed between runs"
|
|
assert "wrapper correct" in r2.stdout, f"2nd run should report correct: {r2.stdout!r}"
|
|
|
|
|
|
def test_wrapper_content(tmp_path: Path) -> None:
|
|
"""Wrapper contains python memory CLI delegation + spawnSync."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
wrapper = tmp_path / "wrapper" / "rag.js"
|
|
r = _run_script(mem, str(remote), wrapper=wrapper)
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
body = wrapper.read_text()
|
|
assert "python" in body, f"wrapper missing python ref: {body!r}"
|
|
assert "-m" in body and "src.memory" in body, "wrapper missing src.memory module"
|
|
assert "spawnSync" in body, "wrapper missing spawnSync"
|
|
assert "process.argv.slice" in body, "wrapper missing argv passthrough"
|
|
|
|
|
|
def test_wrapper_backup_original(tmp_path: Path) -> None:
|
|
"""Pre-existing wrapper with different content → .orig backup saved once."""
|
|
remote = tmp_path / "remote.git"
|
|
_seed_remote(remote)
|
|
mem = tmp_path / "mem"
|
|
wrapper = tmp_path / "wrapper" / "rag.js"
|
|
wrapper.parent.mkdir(parents=True, exist_ok=True)
|
|
original = "# old rag-cli shim\nconsole.log('old');\n"
|
|
wrapper.write_text(original)
|
|
r = _run_script(mem, str(remote), wrapper=wrapper)
|
|
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
|
backup = wrapper.with_suffix(".js.orig")
|
|
assert backup.exists(), ".orig backup not created"
|
|
assert backup.read_text() == original, ".orig does not preserve original content"
|
|
assert wrapper.read_text() != original, "wrapper not replaced with delegate"
|
|
# Second run with different wrong content: .orig must NOT be overwritten.
|
|
wrapper.write_text("# another wrong\n")
|
|
r2 = _run_script(mem, str(remote), wrapper=wrapper)
|
|
assert r2.returncode == 0, f"run2: stdout={r2.stdout}\nstderr={r2.stderr}"
|
|
assert backup.read_text() == original, ".orig overwritten on 2nd backup"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main([__file__, "-v"]))
|