Files
mempalace--mempalace/tests/test_cursor_plugin_manifest.py
wehub-resource-sync c3749daf48
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
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

528 lines
23 KiB
Python

"""Contract tests for ``.cursor-plugin/``.
These tests protect the four things a Cursor user actually relies on
once they install the plugin:
1. The manifest (``.cursor-plugin/plugin.json``) is valid JSON, satisfies
Cursor's required + structural fields, and every component path it
declares resolves to a real on-disk target.
2. The marketplace manifest (``.cursor-plugin/marketplace.json``) is
valid JSON and points at the same plugin.
3. The MCP config (``.cursor-plugin/mcp.json``) is valid JSON, wraps
server entries under the documented ``mcpServers`` key, and
registers the ``mempalace-mcp`` binary that ships with the package.
4. Every skill ``SKILL.md`` and command ``*.md`` parses as YAML
frontmatter + markdown body. Cursor derives the slash-command
slug from the **filename stem** (e.g. ``mempalace-help.md`` →
``/mempalace-help``), so command files do NOT need a ``name``
frontmatter field — only ``description`` is required.
Run with::
uv run pytest tests/test_cursor_plugin_manifest.py -v
All tests are pure file inspection (no subprocesses, no network) and
take milliseconds. They run on every CI platform without needing
Cursor itself.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_DIR = REPO_ROOT / ".cursor-plugin"
MANIFEST_PATH = PLUGIN_DIR / "plugin.json"
MARKETPLACE_PATH = PLUGIN_DIR / "marketplace.json"
MCP_PATH = PLUGIN_DIR / "mcp.json"
README_PATH = PLUGIN_DIR / "README.md"
# Component directories: canonical location is at the plugin root (repo root),
# NOT inside .cursor-plugin/. Cursor's default discovery requires real
# directories at the plugin root; .cursor-plugin/ symlinks back to these.
SKILLS_DIR = REPO_ROOT / "skills"
COMMANDS_DIR = REPO_ROOT / "commands"
RULES_DIR = REPO_ROOT / "rules"
# The slugs we promise to ship. The README's "Available Slash Commands"
# table is the user-facing contract; if you add/remove a command,
# update both the README and this list.
EXPECTED_COMMAND_NAMES = {
"mempalace-help",
"mempalace-init",
"mempalace-mine",
"mempalace-search",
"mempalace-status",
}
# Per cursor.com/docs/reference/plugins: "Plugin identifier. Lowercase,
# kebab-case (alphanumerics, hyphens, and periods). Must start and end
# with an alphanumeric character."
KEBAB_RE = re.compile(r"^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")
# Cursor's submission checklist explicitly forbids these in manifest
# paths: "All paths in manifest are relative and valid (no `..`, no
# absolute paths)." Treat both as hard failures rather than warnings —
# the marketplace review bot would reject the plugin otherwise.
_FORBIDDEN_PATH_FRAGMENTS = ("..",)
# ── helpers ─────────────────────────────────────────────────────────
def _parse_frontmatter(text: str) -> tuple[dict, str]:
"""Split a markdown file with YAML frontmatter into ``(meta, body)``.
The frontmatter must start at byte 0 with a literal ``---\\n`` and
close with another ``---\\n`` line. Files without frontmatter are
treated as having an empty ``meta`` dict so the caller can decide
whether that's acceptable for the file type under test.
"""
if not text.startswith("---\n"):
return {}, text
end = text.find("\n---\n", 4)
if end == -1:
return {}, text
raw = text[4:end]
body = text[end + 5 :]
parsed = yaml.safe_load(raw) or {}
if not isinstance(parsed, dict):
raise AssertionError(f"Frontmatter parsed to {type(parsed).__name__}, expected dict")
return parsed, body
def _is_safe_relative(path_str: str) -> bool:
"""Return True iff ``path_str`` is a relative, ``..``-free path."""
if not isinstance(path_str, str) or not path_str:
return False
p = Path(path_str)
if p.is_absolute():
return False
return not any(part in _FORBIDDEN_PATH_FRAGMENTS for part in p.parts)
# ── plugin.json ─────────────────────────────────────────────────────
class TestPluginManifest:
def test_manifest_exists(self):
assert MANIFEST_PATH.is_file(), f"{MANIFEST_PATH} is missing"
def test_manifest_is_valid_json(self):
json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
def test_manifest_has_required_name_field(self):
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert isinstance(data.get("name"), str) and data["name"], (
"plugin.json must have a non-empty 'name' (only required field per Cursor schema)"
)
def test_manifest_name_is_kebab_case(self):
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert KEBAB_RE.match(data["name"]), (
f"name must be lowercase kebab-case; got {data['name']!r}"
)
def test_manifest_has_recommended_optional_fields(self):
"""``description`` and ``author.name`` aren't required by the
schema but ARE required by the submission checklist, so failing
early here saves a round-trip with the marketplace reviewers."""
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert isinstance(data.get("description"), str) and data["description"]
author = data.get("author")
assert isinstance(author, dict) and isinstance(author.get("name"), str)
assert author["name"], "author.name must be non-empty"
def test_manifest_omits_hardcoded_version(self):
"""plugin.json must NOT hardcode a ``version`` field.
``mempalace/version.py`` is the single source of truth (per
CLAUDE.md). A hardcoded version here silently drifts on the next
release (igorls review, PR #1632). The sibling Antigravity plugin
omits the field entirely; we match that. The marketplace resolves
the package version at publish time.
"""
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
assert "version" not in data, (
"plugin.json must omit the hardcoded 'version' field to avoid "
f"drift from mempalace/version.py; found {data.get('version')!r}"
)
def test_marketplace_entry_omits_hardcoded_version(self):
"""Same drift guard for the marketplace plugin entry."""
data = json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
for plugin in data.get("plugins", []):
if isinstance(plugin, dict):
assert "version" not in plugin, (
"marketplace.json plugin entry must omit the hardcoded "
f"'version' field; found {plugin.get('version')!r}"
)
@pytest.mark.parametrize("field", ["skills", "commands", "mcpServers"])
def test_manifest_component_paths_are_safe(self, field: str):
"""Every path the manifest declares must be relative + ``..``-free.
Cursor's submission checklist rejects ``..`` or absolute paths
outright. We check here so a typo doesn't fail review.
"""
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
value = data.get(field)
if value is None:
return # optional — if missing, default discovery kicks in
if isinstance(value, str):
paths = [value]
elif isinstance(value, list):
paths = [v for v in value if isinstance(v, str)]
else:
return # inline object form; nothing to validate path-wise
for p in paths:
assert _is_safe_relative(p), f"{field}: {p!r} must be relative and contain no '..'"
@pytest.mark.parametrize(
"field,expected_type",
[
("skills", "dir"),
("commands", "dir"),
("mcpServers", "file"),
],
)
def test_manifest_component_paths_resolve(self, field: str, expected_type: str):
"""Every component path must point at a real on-disk target.
Use REPO_ROOT (not PLUGIN_DIR) as the resolution base because
Cursor resolves manifest paths against the plugin root, which
for our layout is the repo root (the dir containing
``.cursor-plugin/``).
"""
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
value = data.get(field)
if not isinstance(value, str):
return # inline object form or absent
target = (REPO_ROOT / value).resolve()
if expected_type == "dir":
assert target.is_dir(), f"{field}={value!r} -> {target} is not a directory"
else:
assert target.is_file(), f"{field}={value!r} -> {target} is not a file"
# ── marketplace.json ────────────────────────────────────────────────
class TestMarketplaceManifest:
def test_marketplace_exists(self):
assert MARKETPLACE_PATH.is_file(), f"{MARKETPLACE_PATH} is missing"
def test_marketplace_is_valid_json(self):
json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
def test_marketplace_required_fields(self):
data = json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
assert isinstance(data.get("name"), str) and data["name"]
owner = data.get("owner")
assert isinstance(owner, dict) and isinstance(owner.get("name"), str)
plugins = data.get("plugins")
assert isinstance(plugins, list) and 1 <= len(plugins) <= 500
def test_marketplace_lists_mempalace_plugin(self):
"""The marketplace must list our plugin, and the listed name must
match the actual ``plugin.json::name`` — otherwise the marketplace
resolver looks up ``my-plugin/.cursor-plugin/plugin.json`` and
gets a name mismatch, which Cursor rejects at install time.
"""
data = json.loads(MARKETPLACE_PATH.read_text(encoding="utf-8"))
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
names = {p.get("name") for p in data["plugins"] if isinstance(p, dict)}
assert manifest["name"] in names, (
f"marketplace.json plugins list does not include {manifest['name']!r}"
)
# ── mcp.json ────────────────────────────────────────────────────────
class TestMcpConfig:
def test_mcp_config_exists(self):
assert MCP_PATH.is_file(), f"{MCP_PATH} is missing"
def test_mcp_config_is_valid_json(self):
json.loads(MCP_PATH.read_text(encoding="utf-8"))
def test_mcp_config_wraps_servers_under_mcpservers_key(self):
"""Per cursor.com/docs/reference/plugins#mcp-servers, the MCP
config file must contain server entries under a ``mcpServers``
key. Using the flat shape (used by Claude's ``.mcp.json``) here
would silently fail to register the server with Cursor.
"""
data = json.loads(MCP_PATH.read_text(encoding="utf-8"))
assert "mcpServers" in data and isinstance(data["mcpServers"], dict), (
"mcp.json must wrap servers under an 'mcpServers' object"
)
def test_mcp_config_registers_mempalace_server(self):
data = json.loads(MCP_PATH.read_text(encoding="utf-8"))
servers = data["mcpServers"]
assert "mempalace" in servers, "mcp.json must register a server named 'mempalace'"
entry = servers["mempalace"]
assert isinstance(entry, dict) and isinstance(entry.get("command"), str)
assert entry["command"] == "mempalace-mcp", (
f"mempalace server command must be 'mempalace-mcp' (the binary "
f"shipped by the package); got {entry.get('command')!r}"
)
# ── skills/ ─────────────────────────────────────────────────────────
class TestSkills:
def test_skills_dir_exists(self):
assert SKILLS_DIR.is_dir()
def test_at_least_one_skill_present(self):
skill_files = list(SKILLS_DIR.glob("*/SKILL.md"))
assert skill_files, (
f"{SKILLS_DIR} must contain at least one <name>/SKILL.md "
"(otherwise Cursor's discovery treats the plugin as having no skills)"
)
def test_mempalace_skill_exists(self):
assert (SKILLS_DIR / "mempalace" / "SKILL.md").is_file()
def test_mempalace_recall_skill_exists(self):
"""The recall skill is the search-before-answer half of the
plugin (the ``mempalace`` skill covers setup/mine/status). If it
goes missing, recall silently regresses to model-memory guessing.
"""
assert (SKILLS_DIR / "mempalace-recall" / "SKILL.md").is_file()
def test_each_skill_has_valid_frontmatter(self):
"""Every SKILL.md must declare ``name`` (kebab-case) and a
non-empty ``description``. Skills missing these fields silently
fail to register in Cursor's skill picker (#1410 equivalent).
"""
for skill_path in SKILLS_DIR.glob("*/SKILL.md"):
text = skill_path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(text)
ctx = f"{skill_path.relative_to(REPO_ROOT)}"
assert meta, f"{ctx}: missing YAML frontmatter"
assert isinstance(meta.get("name"), str) and meta["name"], (
f"{ctx}: 'name' must be a non-empty string"
)
assert KEBAB_RE.match(meta["name"]), (
f"{ctx}: name must be lowercase kebab-case; got {meta['name']!r}"
)
assert isinstance(meta.get("description"), str) and meta["description"], (
f"{ctx}: 'description' must be a non-empty string"
)
assert body.strip(), f"{ctx}: body must not be empty"
def test_skill_name_matches_directory(self):
"""The skill's directory name should equal the frontmatter
``name`` — Cursor displays the directory name in the picker and
the frontmatter name in the API; mismatches confuse both users
and the agent.
"""
for skill_path in SKILLS_DIR.glob("*/SKILL.md"):
meta, _ = _parse_frontmatter(skill_path.read_text(encoding="utf-8"))
dir_name = skill_path.parent.name
assert meta.get("name") == dir_name, (
f"{skill_path.relative_to(REPO_ROOT)}: name={meta.get('name')!r} "
f"must match directory {dir_name!r}"
)
# ── rules/ ──────────────────────────────────────────────────────────
class TestRules:
"""The plugin ships an optional recall rule at the plugin root under
``rules/``. Like skills and commands, rules are discovered from a
real directory at the plugin root (the repo root), not from inside
``.cursor-plugin/``.
"""
def test_rules_dir_exists(self):
assert RULES_DIR.is_dir(), "rules/ missing at repo root"
def test_rules_dir_is_real_not_symlink(self):
assert not RULES_DIR.is_symlink(), (
"rules/ must be a real directory, not a symlink — "
"Cursor does not follow symlinks for local-plugin discovery"
)
def test_recall_rule_exists(self):
assert (RULES_DIR / "mempalace-recall.mdc").is_file()
def test_each_rule_has_valid_frontmatter(self):
"""Every ``.mdc`` rule must declare a non-empty ``description``
(Cursor's matcher reads it to decide relevance) and a boolean
``alwaysApply``. A rule missing ``description`` never auto-applies.
"""
rule_files = list(RULES_DIR.glob("*.mdc"))
assert rule_files, f"{RULES_DIR} must contain at least one .mdc rule"
for rule_path in rule_files:
text = rule_path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(text)
ctx = f"{rule_path.relative_to(REPO_ROOT)}"
assert meta, f"{ctx}: missing YAML frontmatter"
assert isinstance(meta.get("description"), str) and meta["description"], (
f"{ctx}: 'description' must be a non-empty string"
)
assert isinstance(meta.get("alwaysApply"), bool), (
f"{ctx}: 'alwaysApply' must be a boolean"
)
assert body.strip(), f"{ctx}: body must not be empty"
def test_shipped_recall_rule_is_not_always_apply(self):
"""The plugin-shipped recall rule must be ``alwaysApply: false``.
An always-on rule loads on every turn in every workspace the
plugin touches, adding MCP latency to unrelated work and fighting
MemPalace's "memory should feel instant" budget. The aggressive
``alwaysApply: true`` variant is an opt-in shipped only under
examples/, never wired into the default plugin bundle.
"""
meta, _ = _parse_frontmatter(
(RULES_DIR / "mempalace-recall.mdc").read_text(encoding="utf-8")
)
assert meta.get("alwaysApply") is False, (
"the plugin-shipped recall rule must be alwaysApply: false; "
"the always-on variant belongs in examples/cursor/rules/"
)
# ── commands/ ───────────────────────────────────────────────────────
class TestDefaultDiscoveryLayout:
"""Cursor discovers plugin components from real ``commands/``,
``skills/``, and ``mcp.json`` at the *plugin root* (our repo root).
These must be real directories/files — Cursor does not follow
symlinks for local-plugin component discovery. We verified this
behaviour by comparing the cached Cloudflare plugin structure
(all real dirs) against our earlier broken symlink-only attempt.
"""
def test_commands_is_real_dir_at_plugin_root(self):
target = REPO_ROOT / "commands"
assert target.is_dir(), "commands/ missing at repo root"
assert not target.is_symlink(), (
"commands/ must be a real directory, not a symlink — "
"Cursor does not follow symlinks for local-plugin discovery"
)
def test_skills_is_real_dir_at_plugin_root(self):
target = REPO_ROOT / "skills"
assert target.is_dir(), "skills/ missing at repo root"
assert not target.is_symlink(), (
"skills/ must be a real directory, not a symlink — "
"Cursor does not follow symlinks for local-plugin discovery"
)
def test_mcp_json_is_real_file_at_plugin_root(self):
target = REPO_ROOT / "mcp.json"
assert target.is_file(), "mcp.json missing at repo root"
assert not target.is_symlink(), (
"mcp.json must be a real file, not a symlink — "
"Cursor does not follow symlinks for local-plugin discovery"
)
def test_no_symlinks_under_cursor_plugin_dir(self):
"""No path under ``.cursor-plugin/`` may be a symlink.
igorls review (PR #1632): committed symlinks materialise as plain
text files containing the link target on Windows clones with
``core.symlinks=false``, silently breaking the plugin. CI's
manifest tests skip Windows, so this guard runs on every platform.
The canonical components live at the repo root (``source: "."``);
the old ``.cursor-plugin/{commands,skills}`` convenience symlinks
were redundant and have been removed.
"""
offenders = [p for p in PLUGIN_DIR.rglob("*") if p.is_symlink()]
assert not offenders, f"Symlinks under .cursor-plugin/ break Windows clones: {offenders}"
class TestCommands:
def test_commands_dir_exists(self):
assert COMMANDS_DIR.is_dir()
def test_command_set_matches_promised_set(self):
"""The README documents exactly five slash commands. The files
on disk must match that set — no more, no fewer — otherwise the
README is lying to users.
Cursor derives the slash-command slug from the filename stem, so
we compare stems, not frontmatter ``name`` values.
"""
actual = {cmd_path.stem for cmd_path in COMMANDS_DIR.glob("*.md")}
assert actual == EXPECTED_COMMAND_NAMES, (
f"Command file stem set drifted from promised set. "
f"On disk: {sorted(actual)}. "
f"Expected: {sorted(EXPECTED_COMMAND_NAMES)}."
)
def test_each_command_has_valid_frontmatter(self):
"""Every command file must have YAML frontmatter with a non-empty
``description`` and a non-empty body.
Cursor derives the slash-command slug from the filename stem, so
a ``name`` field is intentionally absent — the ``description``
field is what Cursor shows in the command picker.
"""
for cmd_path in COMMANDS_DIR.glob("*.md"):
text = cmd_path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(text)
ctx = f"{cmd_path.relative_to(REPO_ROOT)}"
assert meta, f"{ctx}: missing YAML frontmatter"
assert isinstance(meta.get("description"), str) and meta["description"], (
f"{ctx}: 'description' must be a non-empty string"
)
assert body.strip(), f"{ctx}: body must not be empty"
def test_each_command_name_prefixed_with_mempalace(self):
"""Cursor commands are global (not plugin-namespaced), so every
command file must be named ``mempalace-*.md`` to avoid colliding
with built-in or other-plugin commands.
The slash-command slug is the filename stem, so
``mempalace-help.md`` → ``/mempalace-help``.
"""
for cmd_path in COMMANDS_DIR.glob("*.md"):
stem = cmd_path.stem
assert stem.startswith("mempalace-"), (
f"{cmd_path.relative_to(REPO_ROOT)}: filename stem {stem!r} "
"must be prefixed with 'mempalace-' to avoid global-namespace collisions"
)
# ── README.md ───────────────────────────────────────────────────────
class TestReadme:
def test_readme_exists(self):
assert README_PATH.is_file(), f"{README_PATH} is missing"
def test_readme_documents_every_command(self):
"""If the README has a command table, every command we ship
must be listed in it. This catches the drift case where someone
adds a command file but forgets to update the docs."""
text = README_PATH.read_text(encoding="utf-8")
missing = [name for name in EXPECTED_COMMAND_NAMES if f"/{name}" not in text]
assert not missing, f"README does not document: {missing}"
def test_readme_cross_references_hooks_install_path(self):
"""Hooks are deliberately NOT part of the plugin (they're wired
via hooks/cursor/install.sh). The README must tell users where
to go for that, otherwise users will assume the plugin already
installed the hooks and wonder why nothing saves.
"""
text = README_PATH.read_text(encoding="utf-8")
assert "hooks/cursor/install.sh" in text, (
"README must reference hooks/cursor/install.sh so users know how to enable auto-save"
)