| """ |
| 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 |
|
|
| |
| 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) |
|
|
|
|
| |
|
|
|
|
| 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 |
|
|
|
|
| |
|
|
|
|
| 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") |
|
|
|
|
| |
|
|
|
|
| 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", |
| ) |
| |
| 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.""" |
| |
| |
| 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 |
|
|