WitNote / plugins /pinchtab /tests /test_smcp_compat.py
AUXteam's picture
Upload folder using huggingface_hub
6a7089a verified
"""
SMCP compatibility tests.
Ensures the plugin satisfies SMCP discovery and execution contract:
- --describe returns valid JSON with plugin + commands + parameters
- All commands are invocable with correct args
- Output is always a single JSON object to stdout
- No functionality missing (all PinchTab API operations covered)
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
# Run cli.py as module or script
PLUGIN_DIR = Path(__file__).resolve().parent.parent
CLI = PLUGIN_DIR / "cli.py"
def run_cli(*args: str, timeout: int = 10) -> tuple[str, str, int]:
"""Run cli.py with args; return (stdout, stderr, returncode)."""
proc = subprocess.run(
[sys.executable, str(CLI)] + list(args),
capture_output=True,
text=True,
timeout=timeout,
cwd=str(PLUGIN_DIR),
)
return proc.stdout, proc.stderr, proc.returncode
def run_describe() -> dict:
"""Run --describe and parse JSON."""
out, err, code = run_cli("--describe")
assert code == 0, f"describe failed: stdout={out!r} stderr={err!r}"
return json.loads(out)
# --- Describe contract (SMCP discovery) ---
def test_describe_returns_valid_json():
"""--describe must output a single valid JSON object."""
payload = run_describe()
assert isinstance(payload, dict)
assert "plugin" in payload
assert "commands" in payload
def test_describe_plugin_schema():
"""Plugin object must have name, version, description."""
payload = run_describe()
plugin = payload["plugin"]
assert plugin["name"] == "pinchtab"
assert "version" in plugin
assert isinstance(plugin["version"], str)
assert "description" in plugin
def test_describe_commands_list():
"""Commands must be a list of command objects."""
payload = run_describe()
commands = payload["commands"]
assert isinstance(commands, list)
for cmd in commands:
assert "name" in cmd
assert "description" in cmd
assert "parameters" in cmd
assert isinstance(cmd["parameters"], list)
def test_describe_parameter_schema():
"""Each parameter must have name, type, description, required, default."""
payload = run_describe()
for cmd in payload["commands"]:
for param in cmd["parameters"]:
assert "name" in param
assert param["type"] in ("string", "integer", "number", "boolean", "array", "object")
assert "required" in param
assert isinstance(param["required"], bool)
def test_describe_all_required_commands_present():
"""All PinchTab API operations must be exposed as commands."""
payload = run_describe()
names = {c["name"] for c in payload["commands"]}
required = {
"health",
"instances",
"instance-start",
"instance-stop",
"tabs",
"navigate",
"snapshot",
"action",
"actions",
"text",
"screenshot",
"pdf",
"evaluate",
"cookies-get",
"stealth-status",
}
missing = required - names
assert not missing, f"Missing commands: {missing}"
def test_describe_navigate_has_url():
"""navigate command must have url parameter."""
payload = run_describe()
nav = next(c for c in payload["commands"] if c["name"] == "navigate")
param_names = [p["name"] for p in nav["parameters"]]
assert "url" in param_names
def test_describe_snapshot_has_filter_and_format():
"""snapshot command must have filter and format for token control."""
payload = run_describe()
snap = next(c for c in payload["commands"] if c["name"] == "snapshot")
param_names = [p["name"] for p in snap["parameters"]]
assert "filter" in param_names
assert "format" in param_names
def test_describe_action_has_kind_and_ref():
"""action command must have kind and ref."""
payload = run_describe()
act = next(c for c in payload["commands"] if c["name"] == "action")
param_names = [p["name"] for p in act["parameters"]]
assert "kind" in param_names
def test_describe_pdf_requires_tab_id():
"""pdf command must have tab-id required."""
payload = run_describe()
pdf_cmd = next(c for c in payload["commands"] if c["name"] == "pdf")
tab_param = next((p for p in pdf_cmd["parameters"] if p["name"] == "tab-id"), None)
assert tab_param is not None
assert tab_param["required"] is True
# --- Execution contract: JSON only to stdout ---
def test_help_exits_nonzero():
"""No command should exit 0 and print help only (no JSON)."""
out, err, code = run_cli()
assert code != 0
def test_unknown_command_returns_json_error():
"""Unknown command should print JSON with error_type."""
out, err, code = run_cli("not-a-command")
assert code != 0
data = json.loads(out)
assert "status" in data
assert data.get("status") == "error"
assert "error_type" in data
def test_validation_errors_return_json():
"""Missing required args must return JSON error (validation_error or argument_error)."""
out, err, code = run_cli("navigate", "--base-url", "http://localhost:9867")
assert code != 0
data = json.loads(out)
assert data.get("status") == "error"
assert data.get("error_type") in ("validation_error", "argument_error")
def test_instance_stop_missing_id_returns_validation_error():
"""instance-stop without instance-id must return validation_error."""
out, err, code = run_cli("instance-stop", "--base-url", "http://localhost:9867")
assert code != 0
data = json.loads(out)
assert data.get("status") == "error"
assert data.get("error_type") == "validation_error"
def test_pdf_missing_tab_id_returns_validation_error():
"""pdf without tab-id must return validation_error or argument_error."""
out, err, code = run_cli("pdf", "--base-url", "http://localhost:9867")
assert code != 0
data = json.loads(out)
assert data.get("status") == "error"
assert data.get("error_type") in ("validation_error", "argument_error")
def test_action_missing_kind_returns_validation_error():
"""action without kind must return validation_error or argument_error."""
out, err, code = run_cli("action", "--base-url", "http://localhost:9867")
assert code != 0
data = json.loads(out)
assert data.get("status") == "error"
assert data.get("error_type") in ("validation_error", "argument_error")
def test_actions_missing_actions_returns_error():
"""actions without --actions must fail (arg parse or validation_error)."""
out, err, code = run_cli("actions", "--base-url", "http://localhost:9867")
assert code != 0
data = json.loads(out)
assert data.get("status") == "error"
def test_evaluate_missing_expression_returns_validation_error():
"""evaluate without expression must return validation_error or argument_error."""
out, err, code = run_cli("evaluate", "--base-url", "http://localhost:9867")
assert code != 0
data = json.loads(out)
assert data.get("status") == "error"
assert data.get("error_type") in ("validation_error", "argument_error")
# --- SMCP invocation style: kebab-case args ---
def test_smcp_style_invoke_navigate_with_kebab_args():
"""SMCP passes --url, --instance-id etc.; plugin must accept and return JSON."""
out, err, code = run_cli(
"navigate",
"--base-url", "http://127.0.0.1:9999",
"--url", "https://pinchtab.com",
)
# Server likely unreachable -> connection_error; we only require valid JSON
data = json.loads(out)
assert "status" in data
assert data["status"] in ("success", "error")
if data["status"] == "error":
assert "error_type" in data
def test_smcp_style_invoke_snapshot_with_options():
"""Snapshot with filter and format (common agent pattern)."""
out, err, code = run_cli(
"snapshot",
"--base-url", "http://127.0.0.1:9999",
"--filter", "interactive",
"--format", "compact",
)
data = json.loads(out)
assert "status" in data
def test_smcp_style_invoke_action_click():
"""Action click with ref."""
out, err, code = run_cli(
"action",
"--base-url", "http://127.0.0.1:9999",
"--kind", "click",
"--ref", "e5",
)
data = json.loads(out)
assert "status" in data
def test_success_response_structure():
"""When command succeeds (or server returns 200), response has status success and data."""
# We can't guarantee server is up; test that our success path structure is correct
# by testing that connection_error has status/error/error_type
out, err, code = run_cli("health", "--base-url", "http://127.0.0.1:9999")
data = json.loads(out)
assert "status" in data
if data["status"] == "success":
assert "data" in data
else:
assert "error" in data
assert "error_type" in data