c3749daf48
Tests / test-windows (push) Waiting to run
Tests / test-macos (push) Waiting to run
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
775 lines
31 KiB
Python
775 lines
31 KiB
Python
"""Behavioral coverage for the Cursor hook shell scripts.
|
|
|
|
Mirrors ``tests/test_hooks_shell.py`` + ``tests/test_hooks_bash_compat.py``
|
|
in shape so a future contributor recognises the pattern. The three
|
|
hooks live at ``hooks/cursor/`` and source ``hooks/cursor/lib/common.sh``.
|
|
|
|
Covered contracts:
|
|
|
|
- bash 3.2 compatibility (no ``mapfile`` / ``readarray``; ``sed -n 'Np'``
|
|
used for line extraction; ``bash -n`` clean).
|
|
- Per-conversation counter increments atomically across ``stop`` calls
|
|
and emits a ``followup_message`` only on the configured interval.
|
|
- ``MEMPAL_DISABLE_HOOK=1`` and ``MEMPALACE_HOOKS_AUTO_SAVE=false`` both
|
|
short-circuit every hook to ``{}``.
|
|
- Malformed stdin dumps the payload to a bounded 0600 file and logs a
|
|
warning; the hook still exits 0 with ``{}`` so Cursor proceeds.
|
|
- ``loop_count > 0`` short-circuits the save hook (loop-prevention).
|
|
- A pending-save marker dropped by ``preCompact`` forces a save
|
|
followup on the very next ``stop`` regardless of the counter.
|
|
- ``infer_wing_from_cwd`` handles ``/``, trailing slashes, spaces, and
|
|
empty input.
|
|
- The wake hook emits ``additional_context`` referencing the inferred
|
|
wing.
|
|
- The precompact hook drops a pending-save marker and emits the
|
|
documented ``user_message`` shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
HOOKS_DIR = REPO_ROOT / "hooks" / "cursor"
|
|
SAVE_HOOK = HOOKS_DIR / "mempal_save_hook_cursor.sh"
|
|
PRECOMPACT_HOOK = HOOKS_DIR / "mempal_precompact_hook_cursor.sh"
|
|
WAKE_HOOK = HOOKS_DIR / "mempal_wake_hook_cursor.sh"
|
|
COMMON_LIB = HOOKS_DIR / "lib" / "common.sh"
|
|
|
|
# All three .sh scripts, parametrised together for the source-level and
|
|
# universal-behaviour tests. ids= keeps pytest output readable.
|
|
ALL_HOOKS = pytest.mark.parametrize(
|
|
"hook",
|
|
[SAVE_HOOK, PRECOMPACT_HOOK, WAKE_HOOK],
|
|
ids=["save_hook", "precompact_hook", "wake_hook"],
|
|
)
|
|
|
|
pytestmark = pytest.mark.skipif(os.name == "nt", reason="bash hook scripts are POSIX-only")
|
|
|
|
|
|
# ── helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _run_hook(
|
|
hook: Path,
|
|
stdin: str,
|
|
home: Path,
|
|
*,
|
|
extra_env: dict | None = None,
|
|
path_prefix: list[Path] | None = None,
|
|
expected_rc: int = 0,
|
|
) -> tuple[str, str]:
|
|
"""Invoke a hook with a controlled environment and assert exit code.
|
|
|
|
Returns ``(stdout, stderr)``. Forces ``MEMPAL_PYTHON=sys.executable``
|
|
so the hook always finds a Python that can ``import json`` — without
|
|
this, GUI-launched CI on macOS could hit a missing python3 on the
|
|
inherited PATH and produce spurious failures unrelated to the hook
|
|
logic. Forces a clean ``HOME`` so the state directory is sandboxed
|
|
under the test's ``tmp_path``.
|
|
"""
|
|
env = {
|
|
"HOME": str(home),
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"MEMPAL_PYTHON": sys.executable,
|
|
}
|
|
if path_prefix:
|
|
env["PATH"] = os.pathsep.join(str(p) for p in path_prefix) + os.pathsep + env["PATH"]
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
p = subprocess.run(
|
|
["bash", str(hook)],
|
|
input=stdin,
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
timeout=30,
|
|
# Force a permissive umask so the hook's own ``umask 077`` inside
|
|
# parsing subshells is provably the sole reason diagnostic files
|
|
# end up mode 0600. Without this, an ambient restrictive umask
|
|
# on the CI runner would mask a regression that drops the in-hook
|
|
# ``umask`` line. Mirrors tests/test_hooks_bash_compat.py.
|
|
preexec_fn=lambda: os.umask(0o022),
|
|
)
|
|
assert p.returncode == expected_rc, (
|
|
f"{hook.name} exited {p.returncode} (expected {expected_rc}); "
|
|
f"stderr={p.stderr!r}; stdout={p.stdout!r}"
|
|
)
|
|
return p.stdout, p.stderr
|
|
|
|
|
|
def _stop_payload(
|
|
*,
|
|
conv: str = "conv-1",
|
|
loop_count: int = 0,
|
|
transcript: str = "",
|
|
) -> str:
|
|
return json.dumps(
|
|
{
|
|
"conversation_id": conv,
|
|
"loop_count": loop_count,
|
|
"status": "completed",
|
|
"model": "claude-sonnet-4-20250514",
|
|
"hook_event_name": "stop",
|
|
"transcript_path": transcript,
|
|
"workspace_roots": ["/Users/test/sampleProj"],
|
|
}
|
|
)
|
|
|
|
|
|
def _precompact_payload(*, conv: str = "conv-1", transcript: str = "") -> str:
|
|
return json.dumps(
|
|
{
|
|
"conversation_id": conv,
|
|
"hook_event_name": "preCompact",
|
|
"trigger": "auto",
|
|
"transcript_path": transcript,
|
|
"workspace_roots": ["/Users/test/sampleProj"],
|
|
}
|
|
)
|
|
|
|
|
|
def _session_start_payload(*, conv: str = "conv-1") -> str:
|
|
return json.dumps(
|
|
{
|
|
"conversation_id": conv,
|
|
"session_id": conv,
|
|
"hook_event_name": "sessionStart",
|
|
"is_background_agent": False,
|
|
"composer_mode": "agent",
|
|
"workspace_roots": ["/Users/test/sampleProj"],
|
|
}
|
|
)
|
|
|
|
|
|
def _state_dir(home: Path) -> Path:
|
|
return home / ".mempalace" / "hook_state"
|
|
|
|
|
|
def _log_text(home: Path) -> str:
|
|
log = _state_dir(home) / "cursor_hook.log"
|
|
return log.read_text() if log.exists() else ""
|
|
|
|
|
|
# ── source-level bash 3.2 compat (matches tests/test_hooks_bash_compat.py) ──
|
|
|
|
|
|
class TestBash32Compat:
|
|
@ALL_HOOKS
|
|
def test_bash_syntax_clean(self, hook):
|
|
p = subprocess.run(
|
|
["bash", "-n", str(hook)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert p.returncode == 0, f"{hook.name} syntax error: {p.stderr}"
|
|
|
|
def test_common_lib_syntax_clean(self):
|
|
p = subprocess.run(
|
|
["bash", "-n", str(COMMON_LIB)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert p.returncode == 0, f"common.sh syntax error: {p.stderr}"
|
|
|
|
@ALL_HOOKS
|
|
def test_no_bash4_array_builtins(self, hook):
|
|
src = "\n".join(
|
|
line for line in hook.read_text().splitlines() if not line.lstrip().startswith("#")
|
|
)
|
|
assert "mapfile" not in src, (
|
|
f"{hook.name} uses mapfile; unavailable on macOS /bin/bash 3.2 (#1440)"
|
|
)
|
|
assert "readarray" not in src, (
|
|
f"{hook.name} uses readarray; unavailable on macOS /bin/bash 3.2 (#1440)"
|
|
)
|
|
|
|
def test_common_lib_no_bash4_array_builtins(self):
|
|
src = "\n".join(
|
|
line
|
|
for line in COMMON_LIB.read_text().splitlines()
|
|
if not line.lstrip().startswith("#")
|
|
)
|
|
assert "mapfile" not in src and "readarray" not in src, (
|
|
"common.sh uses bash-4-only array builtins; would break macOS bash 3.2"
|
|
)
|
|
|
|
def test_common_lib_uses_sed_n_for_extraction(self):
|
|
# Defense: if a future edit swaps the sed-based parser for
|
|
# mapfile (the bash-4 form), this catches it at source level
|
|
# before any behavioural test runs. ``parse_cursor_stdin`` reads
|
|
# seven values (sentinel + six fields) so we expect at least
|
|
# seven ``sed -n 'Np'`` calls.
|
|
src = "\n".join(
|
|
line
|
|
for line in COMMON_LIB.read_text().splitlines()
|
|
if not line.lstrip().startswith("#")
|
|
)
|
|
assert src.count("sed -n '") >= 7, (
|
|
"common.sh must use sed -n 'Np' for POSIX-portable line extraction"
|
|
)
|
|
|
|
|
|
# ── kill switches ───────────────────────────────────────────────────
|
|
|
|
|
|
class TestKillSwitches:
|
|
@ALL_HOOKS
|
|
@pytest.mark.parametrize("value", ["1", "true", "yes", "on"])
|
|
def test_disable_hook_env_short_circuits(self, hook, value, tmp_path):
|
|
out, _ = _run_hook(
|
|
hook,
|
|
_stop_payload(),
|
|
tmp_path,
|
|
extra_env={"MEMPAL_DISABLE_HOOK": value},
|
|
)
|
|
assert json.loads(out) == {}, f"MEMPAL_DISABLE_HOOK={value} must short-circuit; got {out!r}"
|
|
# No state files should be created when the kill switch fires.
|
|
state = _state_dir(tmp_path)
|
|
assert not (state / "cursor_hook.log").exists() or _log_text(tmp_path) == ""
|
|
|
|
@ALL_HOOKS
|
|
@pytest.mark.parametrize("value", ["false", "0", "no", "off"])
|
|
def test_auto_save_env_short_circuits(self, hook, value, tmp_path):
|
|
out, _ = _run_hook(
|
|
hook,
|
|
_stop_payload(),
|
|
tmp_path,
|
|
extra_env={"MEMPALACE_HOOKS_AUTO_SAVE": value},
|
|
)
|
|
assert json.loads(out) == {}, (
|
|
f"MEMPALACE_HOOKS_AUTO_SAVE={value} must short-circuit; got {out!r}"
|
|
)
|
|
|
|
@ALL_HOOKS
|
|
def test_config_file_auto_save_false_short_circuits(self, hook, tmp_path):
|
|
cfg_dir = tmp_path / ".mempalace"
|
|
cfg_dir.mkdir(parents=True)
|
|
(cfg_dir / "config.json").write_text(json.dumps({"hooks": {"auto_save": False}}))
|
|
out, _ = _run_hook(hook, _stop_payload(), tmp_path)
|
|
assert json.loads(out) == {}, (
|
|
f"config.json hooks.auto_save=false must short-circuit; got {out!r}"
|
|
)
|
|
|
|
|
|
# ── malformed stdin ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestMalformedStdin:
|
|
@ALL_HOOKS
|
|
def test_malformed_input_does_not_crash(self, hook, tmp_path):
|
|
out, _ = _run_hook(hook, "not-json garbage", tmp_path)
|
|
# Must still produce parseable JSON so Cursor proceeds.
|
|
assert json.loads(out) == {}, f"hook must emit {{}} on malformed input; got {out!r}"
|
|
|
|
@ALL_HOOKS
|
|
def test_malformed_input_logs_warning_and_dumps_payload(self, hook, tmp_path):
|
|
_run_hook(hook, "not-json garbage", tmp_path)
|
|
state = _state_dir(tmp_path)
|
|
log = (state / "cursor_hook.log").read_text()
|
|
assert "WARN: input parse failed" in log, (
|
|
f"expected parse-failure warning in log; got: {log!r}"
|
|
)
|
|
dump = state / "cursor_last_input.log"
|
|
assert dump.exists()
|
|
assert "not-json garbage" in dump.read_text()
|
|
|
|
@ALL_HOOKS
|
|
def test_dump_is_mode_0600(self, hook, tmp_path):
|
|
_run_hook(hook, "not-json garbage", tmp_path)
|
|
dump = _state_dir(tmp_path) / "cursor_last_input.log"
|
|
mode = stat.S_IMODE(dump.stat().st_mode)
|
|
assert mode == 0o600, f"cursor_last_input.log mode should be 0600, got {oct(mode)}"
|
|
|
|
@ALL_HOOKS
|
|
def test_dump_cap_at_4096_bytes(self, hook, tmp_path):
|
|
_run_hook(hook, "x" * 4097, tmp_path)
|
|
dump = _state_dir(tmp_path) / "cursor_last_input.log"
|
|
assert dump.stat().st_size == 4096, (
|
|
f"cap must be exactly 4096 bytes; got {dump.stat().st_size}"
|
|
)
|
|
|
|
@ALL_HOOKS
|
|
def test_empty_stdin_does_not_dump(self, hook, tmp_path):
|
|
out, _ = _run_hook(hook, "", tmp_path)
|
|
assert json.loads(out) == {}
|
|
dump = _state_dir(tmp_path) / "cursor_last_input.log"
|
|
assert not dump.exists(), "empty stdin must not produce a dump file"
|
|
|
|
@ALL_HOOKS
|
|
def test_successful_parse_leaves_no_python_err_log(self, hook, tmp_path):
|
|
if hook == SAVE_HOOK:
|
|
payload = _stop_payload()
|
|
elif hook == PRECOMPACT_HOOK:
|
|
payload = _precompact_payload()
|
|
else:
|
|
payload = _session_start_payload()
|
|
_run_hook(hook, payload, tmp_path)
|
|
err_log = _state_dir(tmp_path) / "cursor_last_python_err.log"
|
|
assert not err_log.exists(), "successful parse must clean up cursor_last_python_err.log"
|
|
|
|
|
|
# ── save hook: counter + threshold ──────────────────────────────────
|
|
|
|
|
|
class TestSaveHookCounter:
|
|
def test_counter_increments_across_invocations(self, tmp_path):
|
|
for _ in range(3):
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
|
|
assert json.loads(out) == {}, "below threshold must be a no-op"
|
|
counter_file = _state_dir(tmp_path) / "cursor_conv-A.count"
|
|
assert counter_file.read_text() == "3", (
|
|
f"counter should be 3 after 3 invocations; got {counter_file.read_text()!r}"
|
|
)
|
|
|
|
def test_counter_per_conversation_isolated(self, tmp_path):
|
|
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
|
|
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-A"), tmp_path)
|
|
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-B"), tmp_path)
|
|
assert (_state_dir(tmp_path) / "cursor_conv-A.count").read_text() == "2"
|
|
assert (_state_dir(tmp_path) / "cursor_conv-B.count").read_text() == "1"
|
|
|
|
def test_threshold_emits_followup_message(self, tmp_path):
|
|
# Lower the interval to keep the test fast.
|
|
env = {"MEMPAL_SAVE_INTERVAL": "3"}
|
|
for _ in range(2):
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
|
|
assert json.loads(out) == {}
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
|
|
response = json.loads(out)
|
|
assert "followup_message" in response, (
|
|
f"third invocation must emit a followup_message; got {response!r}"
|
|
)
|
|
msg = response["followup_message"]
|
|
# Followup must reference the real MCP tool name (regression
|
|
# guard against future typos that would silently fail). The save
|
|
# is driven by a single batch checkpoint call.
|
|
assert "mempalace_checkpoint" in msg
|
|
assert "cursor-ide" in msg, "diary entries must be tagged agent_name=cursor-ide"
|
|
|
|
def test_threshold_followup_references_inferred_wing(self, tmp_path):
|
|
env = {"MEMPAL_SAVE_INTERVAL": "1"}
|
|
# workspace_roots[0] = /Users/test/sampleProj -> wing=sampleproj
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
|
|
msg = json.loads(out)["followup_message"]
|
|
assert "sampleproj" in msg, f"followup should reference inferred wing; got {msg!r}"
|
|
|
|
def test_save_interval_zero_is_coerced_to_default(self, tmp_path):
|
|
"""Regression for gh-PR review: MEMPAL_SAVE_INTERVAL=0 would
|
|
otherwise crash bash on `$((NEXT % 0))` (division by zero).
|
|
Zero must be coerced to the default interval (15) so the hook
|
|
survives a misconfigured env var without exiting non-zero.
|
|
"""
|
|
env = {"MEMPAL_SAVE_INTERVAL": "0"}
|
|
# Three independent invocations: each must succeed (rc=0) and
|
|
# emit {} since the coerced interval (15) is never reached.
|
|
for _ in range(3):
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
|
|
assert json.loads(out) == {}, (
|
|
f"SAVE_INTERVAL=0 must coerce to default and pass through; got {out!r}"
|
|
)
|
|
|
|
|
|
# ── save hook: followup opt-out ─────────────────────────────────────
|
|
|
|
|
|
class TestSaveHookFollowupSilence:
|
|
"""The Cursor followup_message is ON by default (it is the
|
|
load-bearing verbatim path because Cursor's transcript is unminable),
|
|
but users can silence it. These tests lock the opt-out contract.
|
|
"""
|
|
|
|
def test_followup_on_by_default_at_threshold(self, tmp_path):
|
|
"""Sanity baseline: with no silence flag, the threshold emits a
|
|
followup. Guards against an accidental default flip."""
|
|
env = {"MEMPAL_SAVE_INTERVAL": "1"}
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
|
|
assert "followup_message" in json.loads(out)
|
|
|
|
@pytest.mark.parametrize("value", ["1", "true", "yes", "on"])
|
|
def test_cursor_silent_suppresses_followup(self, value, tmp_path):
|
|
env = {"MEMPAL_SAVE_INTERVAL": "1", "MEMPAL_CURSOR_SILENT": value}
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
|
|
assert json.loads(out) == {}, (
|
|
f"MEMPAL_CURSOR_SILENT={value!r} must suppress the followup; got {out!r}"
|
|
)
|
|
|
|
@pytest.mark.parametrize("value", ["false", "0", "no", "off"])
|
|
def test_verbose_false_suppresses_followup(self, value, tmp_path):
|
|
env = {"MEMPAL_SAVE_INTERVAL": "1", "MEMPAL_VERBOSE": value}
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(), tmp_path, extra_env=env)
|
|
assert json.loads(out) == {}, (
|
|
f"MEMPAL_VERBOSE={value!r} must suppress the followup; got {out!r}"
|
|
)
|
|
|
|
def test_silenced_followup_still_increments_counter(self, tmp_path):
|
|
"""Silence must not disable bookkeeping — the counter still
|
|
advances so cadence is preserved if the user re-enables."""
|
|
env = {"MEMPAL_SAVE_INTERVAL": "5", "MEMPAL_CURSOR_SILENT": "1"}
|
|
for _ in range(2):
|
|
_run_hook(SAVE_HOOK, _stop_payload(conv="conv-S"), tmp_path, extra_env=env)
|
|
counter = _state_dir(tmp_path) / "cursor_conv-S.count"
|
|
assert counter.exists() and counter.read_text().strip() == "2", (
|
|
"silenced followup must still maintain the per-conversation counter"
|
|
)
|
|
|
|
def test_silenced_pending_marker_emits_empty(self, tmp_path):
|
|
"""A consumed pending marker normally forces a followup; under
|
|
silence it must emit {} but still clear the marker."""
|
|
env = {"MEMPAL_CURSOR_SILENT": "1"}
|
|
pending = _state_dir(tmp_path) / "cursor_conv-P.pending"
|
|
pending.parent.mkdir(parents=True, exist_ok=True)
|
|
pending.touch()
|
|
out, _ = _run_hook(SAVE_HOOK, _stop_payload(conv="conv-P"), tmp_path, extra_env=env)
|
|
assert json.loads(out) == {}
|
|
assert not pending.exists(), "pending marker must be consumed even when silenced"
|
|
|
|
|
|
# ── save hook: loop-prevention ──────────────────────────────────────
|
|
|
|
|
|
class TestSaveHookLoopPrevention:
|
|
def test_loop_count_gt_zero_short_circuits(self, tmp_path):
|
|
out, _ = _run_hook(
|
|
SAVE_HOOK,
|
|
_stop_payload(loop_count=1),
|
|
tmp_path,
|
|
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
|
|
)
|
|
assert json.loads(out) == {}, (
|
|
"loop_count > 0 must short-circuit even at the trigger interval"
|
|
)
|
|
# No counter file should be written in the short-circuit path.
|
|
assert not (_state_dir(tmp_path) / "cursor_conv-1.count").exists()
|
|
|
|
def test_loop_count_zero_does_not_short_circuit(self, tmp_path):
|
|
out, _ = _run_hook(
|
|
SAVE_HOOK,
|
|
_stop_payload(loop_count=0),
|
|
tmp_path,
|
|
extra_env={"MEMPAL_SAVE_INTERVAL": "1"},
|
|
)
|
|
assert "followup_message" in json.loads(out)
|
|
|
|
|
|
# ── save hook: pending-save marker from preCompact ──────────────────
|
|
|
|
|
|
class TestPendingSaveMarker:
|
|
def test_pending_marker_forces_followup_regardless_of_counter(self, tmp_path):
|
|
state = _state_dir(tmp_path)
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
# Drop the marker as if precompact had run.
|
|
(state / "cursor_conv-1.pending").write_text("")
|
|
out, _ = _run_hook(
|
|
SAVE_HOOK,
|
|
_stop_payload(),
|
|
tmp_path,
|
|
# SAVE_INTERVAL=1000 ensures the normal counter path would
|
|
# not trigger; the marker is the only reason a followup
|
|
# gets emitted.
|
|
extra_env={"MEMPAL_SAVE_INTERVAL": "1000"},
|
|
)
|
|
response = json.loads(out)
|
|
assert "followup_message" in response, (
|
|
"pending marker must force a followup even far below threshold"
|
|
)
|
|
# Marker must be consumed on read.
|
|
assert not (state / "cursor_conv-1.pending").exists(), (
|
|
"pending marker must be deleted after consumption"
|
|
)
|
|
|
|
def test_pending_marker_is_per_conversation(self, tmp_path):
|
|
state = _state_dir(tmp_path)
|
|
state.mkdir(parents=True, exist_ok=True)
|
|
(state / "cursor_conv-OTHER.pending").write_text("")
|
|
out, _ = _run_hook(
|
|
SAVE_HOOK,
|
|
_stop_payload(conv="conv-1"),
|
|
tmp_path,
|
|
extra_env={"MEMPAL_SAVE_INTERVAL": "1000"},
|
|
)
|
|
# conv-1 has no marker -> counter path -> no trigger -> {}.
|
|
assert json.loads(out) == {}
|
|
# conv-OTHER marker must NOT be consumed by conv-1's invocation.
|
|
assert (state / "cursor_conv-OTHER.pending").exists()
|
|
|
|
|
|
# ── preCompact hook ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestPreCompactHook:
|
|
def test_emits_user_message(self, tmp_path):
|
|
out, _ = _run_hook(PRECOMPACT_HOOK, _precompact_payload(), tmp_path)
|
|
response = json.loads(out)
|
|
# Cursor's preCompact only accepts user_message; never
|
|
# followup_message or decision.
|
|
assert "user_message" in response, f"expected user_message; got {response!r}"
|
|
assert "followup_message" not in response
|
|
assert "decision" not in response
|
|
|
|
def test_drops_pending_marker(self, tmp_path):
|
|
_run_hook(PRECOMPACT_HOOK, _precompact_payload(conv="conv-X"), tmp_path)
|
|
marker = _state_dir(tmp_path) / "cursor_conv-X.pending"
|
|
assert marker.exists(), "preCompact must drop a pending-save marker"
|
|
|
|
def test_logs_trigger(self, tmp_path):
|
|
_run_hook(PRECOMPACT_HOOK, _precompact_payload(conv="conv-Y"), tmp_path)
|
|
log = _log_text(tmp_path)
|
|
assert "event=preCompact" in log
|
|
assert "conv=conv-Y" in log
|
|
assert "trigger=auto" in log
|
|
|
|
|
|
# ── wake (sessionStart) hook ───────────────────────────────────────
|
|
|
|
|
|
class TestWakeHook:
|
|
def test_emits_additional_context(self, tmp_path):
|
|
out, _ = _run_hook(WAKE_HOOK, _session_start_payload(), tmp_path)
|
|
response = json.loads(out)
|
|
assert "additional_context" in response, (
|
|
f"sessionStart must emit additional_context; got {response!r}"
|
|
)
|
|
ctx = response["additional_context"]
|
|
# Must reference the inferred wing AND the real MCP tools.
|
|
assert "sampleproj" in ctx, f"context should reference inferred wing; got {ctx!r}"
|
|
assert "mempalace_search" in ctx
|
|
assert "mempalace_diary_read" in ctx
|
|
assert "cursor-ide" in ctx
|
|
|
|
def test_falls_back_to_env_when_workspace_roots_missing(self, tmp_path):
|
|
# Cursor always provides workspace_roots, but the env-var
|
|
# fallback path needs coverage so a future Cursor schema
|
|
# change cannot silently break the wake hook.
|
|
payload = json.dumps(
|
|
{
|
|
"conversation_id": "conv-Z",
|
|
"session_id": "conv-Z",
|
|
"hook_event_name": "sessionStart",
|
|
"is_background_agent": False,
|
|
"composer_mode": "agent",
|
|
}
|
|
)
|
|
out, _ = _run_hook(
|
|
WAKE_HOOK,
|
|
payload,
|
|
tmp_path,
|
|
extra_env={"CURSOR_PROJECT_DIR": "/Users/test/envFallback"},
|
|
)
|
|
ctx = json.loads(out)["additional_context"]
|
|
assert "envfallback" in ctx, (
|
|
f"env-var fallback workspace should drive wing inference; got {ctx!r}"
|
|
)
|
|
|
|
|
|
# ── infer_wing_from_cwd via direct function call ────────────────────
|
|
|
|
|
|
def _call_infer_wing(arg: str) -> str:
|
|
"""Source common.sh in a bash subshell and invoke mempal_infer_wing.
|
|
|
|
Returns the function's stdout. Uses bash -c so we never have to
|
|
pollute the test's own shell environment with the common.sh state
|
|
(which mkdir's directories and resolves Python paths).
|
|
"""
|
|
script = f'. "{COMMON_LIB}" >/dev/null 2>&1; mempal_infer_wing "$1"'
|
|
# The argument is passed as a positional so it survives any shell
|
|
# quirks around spaces/empty values exactly as the production hook
|
|
# would see them.
|
|
p = subprocess.run(
|
|
["bash", "-c", script, "_test", arg],
|
|
capture_output=True,
|
|
text=True,
|
|
env={
|
|
"HOME": "/tmp",
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"MEMPAL_PYTHON": sys.executable,
|
|
},
|
|
timeout=10,
|
|
)
|
|
assert p.returncode == 0, f"infer_wing call failed: {p.stderr!r}"
|
|
return p.stdout
|
|
|
|
|
|
class TestInferWing:
|
|
def test_basename_of_normal_path(self):
|
|
assert _call_infer_wing("/Users/me/myproject") == "myproject"
|
|
|
|
def test_strips_trailing_slash(self):
|
|
assert _call_infer_wing("/Users/me/myproject/") == "myproject"
|
|
|
|
def test_root_path_falls_back(self):
|
|
assert _call_infer_wing("/") == "root"
|
|
|
|
def test_empty_input_falls_back(self):
|
|
assert _call_infer_wing("") == "cursor_session"
|
|
|
|
def test_spaces_collapsed_to_underscore(self):
|
|
assert _call_infer_wing("/Users/me/my project") == "my_project"
|
|
|
|
def test_lowercases_uppercase_basename(self):
|
|
# Cursor on macOS often hands us /Users/<user>/Projects/MyApp.
|
|
# The wing scoping in MemPalace's MCP tools is case-sensitive,
|
|
# so the wake hook and save hook must produce identical wings
|
|
# for the same workspace — lowercasing is the simplest
|
|
# contract.
|
|
assert _call_infer_wing("/Users/me/MyApp") == "myapp"
|
|
|
|
def test_windows_style_path(self):
|
|
# Cursor on Windows passes C:\path\to\Project as workspace_root.
|
|
# The hook scripts are POSIX-only (we skip them on Windows) but
|
|
# WSL users may still hit a backslash-bearing path via the
|
|
# CURSOR_PROJECT_DIR env var when Cursor is launched from
|
|
# PowerShell.
|
|
assert _call_infer_wing(r"C:\Users\me\MyProj") == "myproj"
|
|
|
|
|
|
# ── state-file TTL + GC ─────────────────────────────────────────────
|
|
|
|
|
|
def _run_common_snippet(snippet: str, home: Path, *, extra_env: dict | None = None) -> str:
|
|
"""Source common.sh and run a bash snippet against a sandboxed HOME.
|
|
|
|
Returns stdout. Used to exercise mempal_state_ttl_days /
|
|
mempal_gc_stale_state directly without going through a full hook.
|
|
"""
|
|
script = f'. "{COMMON_LIB}" >/dev/null 2>&1; {snippet}'
|
|
env = {
|
|
"HOME": str(home),
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"MEMPAL_PYTHON": sys.executable,
|
|
}
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
p = subprocess.run(
|
|
["bash", "-c", script, "_test"],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
timeout=15,
|
|
)
|
|
assert p.returncode == 0, f"snippet failed: {p.stderr!r}"
|
|
return p.stdout
|
|
|
|
|
|
def _age_file(path: Path, days: int) -> None:
|
|
old = time.time() - days * 86400
|
|
os.utime(path, (old, old))
|
|
|
|
|
|
class TestStateTtlDays:
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
("", "30"),
|
|
("abc", "30"),
|
|
("45", "45"),
|
|
("08", "8"),
|
|
("007", "7"),
|
|
("0", "0"),
|
|
],
|
|
)
|
|
def test_ttl_validation_and_octal_strip(self, value, expected, tmp_path):
|
|
out = _run_common_snippet(
|
|
"mempal_state_ttl_days",
|
|
tmp_path,
|
|
extra_env={"MEMPAL_STATE_TTL_DAYS": value} if value != "" else {},
|
|
)
|
|
assert out.strip() == expected, (
|
|
f"MEMPAL_STATE_TTL_DAYS={value!r} should resolve to {expected!r}; got {out.strip()!r}"
|
|
)
|
|
|
|
def test_ttl_default_when_unset(self, tmp_path):
|
|
assert _run_common_snippet("mempal_state_ttl_days", tmp_path).strip() == "30"
|
|
|
|
|
|
class TestStateGc:
|
|
def test_removes_stale_count_and_pending(self, tmp_path):
|
|
sd = _state_dir(tmp_path)
|
|
sd.mkdir(parents=True, exist_ok=True)
|
|
stale_count = sd / "cursor_old.count"
|
|
stale_pending = sd / "cursor_old.pending"
|
|
fresh_count = sd / "cursor_new.count"
|
|
for f in (stale_count, stale_pending, fresh_count):
|
|
f.write_text("1")
|
|
_age_file(stale_count, 40)
|
|
_age_file(stale_pending, 40)
|
|
_run_common_snippet("mempal_gc_stale_state", tmp_path)
|
|
assert not stale_count.exists(), "stale .count older than TTL must be swept"
|
|
assert not stale_pending.exists(), "stale .pending older than TTL must be swept"
|
|
assert fresh_count.exists(), "recent state must be preserved"
|
|
|
|
def test_preserves_shared_logs_and_other_editor_state(self, tmp_path):
|
|
sd = _state_dir(tmp_path)
|
|
sd.mkdir(parents=True, exist_ok=True)
|
|
# Shared logs + another editor's state, all aged well past the TTL.
|
|
keep = [
|
|
sd / "cursor_hook.log",
|
|
sd / "cursor_last_input.log",
|
|
sd / "cursor_last_python_err.log",
|
|
sd / "antigravity_save_count_xyz",
|
|
sd / "hook.log",
|
|
]
|
|
for f in keep:
|
|
f.write_text("x")
|
|
_age_file(f, 99)
|
|
_run_common_snippet("mempal_gc_stale_state", tmp_path)
|
|
for f in keep:
|
|
assert f.exists(), f"GC must never touch {f.name}"
|
|
|
|
def test_creates_sweep_marker(self, tmp_path):
|
|
_run_common_snippet("mempal_gc_stale_state", tmp_path)
|
|
assert (_state_dir(tmp_path) / "cursor_last_sweep").exists()
|
|
|
|
def test_throttled_within_24h(self, tmp_path):
|
|
sd = _state_dir(tmp_path)
|
|
sd.mkdir(parents=True, exist_ok=True)
|
|
# A fresh sweep marker must suppress a second sweep, so a stale
|
|
# file created afterwards survives until the throttle expires.
|
|
(sd / "cursor_last_sweep").write_text("")
|
|
stale = sd / "cursor_old.count"
|
|
stale.write_text("1")
|
|
_age_file(stale, 40)
|
|
_run_common_snippet("mempal_gc_stale_state", tmp_path)
|
|
assert stale.exists(), "GC must be throttled when last_sweep is recent"
|
|
|
|
def test_gc_gated_by_kill_switch(self, tmp_path):
|
|
"""A disabled hook must not sweep (or even create the marker)."""
|
|
sd = _state_dir(tmp_path)
|
|
sd.mkdir(parents=True, exist_ok=True)
|
|
stale = sd / "cursor_zombie.count"
|
|
stale.write_text("1")
|
|
_age_file(stale, 40)
|
|
_run_hook(
|
|
SAVE_HOOK,
|
|
_stop_payload(),
|
|
tmp_path,
|
|
extra_env={"MEMPAL_DISABLE_HOOK": "1"},
|
|
)
|
|
assert stale.exists(), "disabled hook must not GC state"
|
|
assert not (sd / "cursor_last_sweep").exists(), (
|
|
"disabled hook must not even create the sweep marker"
|
|
)
|
|
|
|
|
|
# ── logging discipline ─────────────────────────────────────────────
|
|
|
|
|
|
class TestLogging:
|
|
def test_log_uses_iso8601_utc_timestamps(self, tmp_path):
|
|
_run_hook(SAVE_HOOK, _stop_payload(), tmp_path)
|
|
log = _log_text(tmp_path)
|
|
# ISO 8601 with 'Z' suffix means UTC, locale-independent.
|
|
# Regression guard against switching back to %H:%M:%S which
|
|
# loses both the date and the timezone.
|
|
assert "T" in log and "Z]" in log, f"log timestamps must be ISO 8601 UTC; got: {log!r}"
|