277 lines
15 KiB
Python
277 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
|
|
DEFAULT_BASE_URL = "http://docker.cin.su:8011"
|
|
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
|
|
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
|
|
|
|
|
|
def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]:
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
method="POST",
|
|
headers={"Content-Type": "application/json", **(headers or {})},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
raw = response.read().decode("utf-8")
|
|
data = json.loads(raw) if raw else {}
|
|
return dict(response.headers), data if isinstance(data, dict) else {"status": "error", "error": "response_not_object", "response": data}
|
|
|
|
|
|
def rest_rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
|
|
_, data = post_json(
|
|
base_url.rstrip("/") + "/rpc",
|
|
{"method": method, "payload": payload},
|
|
timeout=timeout,
|
|
headers={"Content-Type": "application/json; charset=utf-8"},
|
|
)
|
|
return data
|
|
|
|
|
|
def mcp_initialize(mcp_url: str, timeout: float) -> tuple[str | None, dict[str, Any]]:
|
|
body = {
|
|
"jsonrpc": "2.0",
|
|
"id": "saved-state-changes-initialize",
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-06-18",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "smoke_1c_saved_state_changes", "version": "1"},
|
|
},
|
|
}
|
|
try:
|
|
headers, data = post_json(
|
|
mcp_url.rstrip("/") + "/mcp",
|
|
body,
|
|
timeout=timeout,
|
|
headers={"Accept": "application/json, text/event-stream"},
|
|
)
|
|
except HTTPError as exc:
|
|
return None, {"status": "error", "error": "mcp_initialize_http_error", "http_status": exc.code, "response": exc.read().decode("utf-8", errors="replace")}
|
|
except URLError as exc:
|
|
return None, {"status": "error", "error": "mcp_initialize_url_error", "message": str(exc)}
|
|
if "error" in data:
|
|
return None, {"status": "error", "error": "mcp_initialize_jsonrpc_error", "details": data.get("error")}
|
|
return headers.get("Mcp-Session-Id"), data
|
|
|
|
|
|
def mcp_rpc(mcp_url: str, method: str, payload: dict[str, Any], timeout: float, session_id: str | None) -> dict[str, Any]:
|
|
headers = {"Accept": "application/json, text/event-stream"}
|
|
if session_id:
|
|
headers["Mcp-Session-Id"] = session_id
|
|
body = {
|
|
"jsonrpc": "2.0",
|
|
"id": f"saved-state-changes-{method}",
|
|
"method": "tools/call",
|
|
"params": {"name": "onec_request", "arguments": {"method": method, "payload": payload}},
|
|
}
|
|
try:
|
|
_, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers=headers)
|
|
except HTTPError as exc:
|
|
return {"status": "error", "error": "mcp_http_error", "http_status": exc.code, "response": exc.read().decode("utf-8", errors="replace")}
|
|
except URLError as exc:
|
|
return {"status": "error", "error": "mcp_url_error", "message": str(exc)}
|
|
if "error" in data:
|
|
return {"status": "error", "error": "mcp_jsonrpc_error", "details": data.get("error")}
|
|
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
|
content = result.get("content") if isinstance(result.get("content"), list) else []
|
|
first = content[0] if content and isinstance(content[0], dict) else {}
|
|
text = first.get("text")
|
|
if not isinstance(text, str):
|
|
return {"status": "error", "error": "mcp_tool_text_missing", "result": result}
|
|
try:
|
|
decoded = json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
return {"status": "error", "error": "mcp_tool_text_not_json", "message": str(exc), "text": text[:500]}
|
|
return decoded if isinstance(decoded, dict) else {"status": "error", "error": "mcp_tool_payload_not_object", "payload": decoded}
|
|
|
|
|
|
def rpc_call(endpoint_url: str, method: str, payload: dict[str, Any], timeout: float, *, transport: str, session_id: str | None) -> dict[str, Any]:
|
|
if transport == "mcp":
|
|
return mcp_rpc(endpoint_url, method, payload, timeout, session_id)
|
|
return rest_rpc(endpoint_url, method, payload, timeout)
|
|
|
|
|
|
def require(condition: bool, message: str, failures: list[str]) -> None:
|
|
if not condition:
|
|
failures.append(message)
|
|
|
|
|
|
def method_names(help_result: dict[str, Any]) -> set[str]:
|
|
return {str(item.get("name") or "") for item in help_result.get("methods") or [] if isinstance(item, dict)}
|
|
|
|
|
|
def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]:
|
|
failures: list[str] = []
|
|
checks: dict[str, Any] = {}
|
|
session_id: str | None = None
|
|
initialize_result: dict[str, Any] | None = None
|
|
if transport == "mcp":
|
|
session_id, initialize_result = mcp_initialize(endpoint_url, timeout)
|
|
contract_version = (((initialize_result.get("result") or {}).get("serverInfo") or {}).get("contract_version") if isinstance(initialize_result.get("result"), dict) else None)
|
|
checks["mcp.initialize"] = {
|
|
"status": "ok" if session_id and contract_version == EXPECTED_CONTRACT_VERSION else "error",
|
|
"session": bool(session_id),
|
|
"contract_version": contract_version,
|
|
}
|
|
require(bool(session_id), "MCP initialize must return Mcp-Session-Id", failures)
|
|
require(contract_version == EXPECTED_CONTRACT_VERSION, "MCP initialize must report expected contract version", failures)
|
|
|
|
help_result = rpc_call(endpoint_url, "help.methods", {}, timeout, transport=transport, session_id=session_id)
|
|
names = method_names(help_result)
|
|
checks["method_exposed"] = {
|
|
"status": "ok" if "metadata.saved_state.changes.list" in names else "missing",
|
|
"method_count": len(names),
|
|
"method": "metadata.saved_state.changes.list",
|
|
}
|
|
require("metadata.saved_state.changes.list" in names, "metadata.saved_state.changes.list must be exposed by help.methods", failures)
|
|
|
|
changes = rpc_call(
|
|
endpoint_url,
|
|
"metadata.saved_state.changes.list",
|
|
{"base_id": base_id, "limit": 50, "timeout_seconds": int(timeout), "include_storage": True},
|
|
timeout,
|
|
transport=transport,
|
|
session_id=session_id,
|
|
)
|
|
counts = changes.get("counts") if isinstance(changes.get("counts"), dict) else {}
|
|
freshness = changes.get("freshness") if isinstance(changes.get("freshness"), dict) else {}
|
|
files = changes.get("files") if isinstance(changes.get("files"), list) else []
|
|
first_file = files[0] if files and isinstance(files[0], dict) else {}
|
|
checks["changes_list"] = {
|
|
"schema": changes.get("schema"),
|
|
"status": changes.get("status"),
|
|
"freshness": freshness.get("status"),
|
|
"verified_against_sql": freshness.get("verified_against_sql"),
|
|
"tables": counts.get("tables"),
|
|
"files": counts.get("files"),
|
|
"changed_files": counts.get("changed_files"),
|
|
"saved_only_files": counts.get("saved_only_files"),
|
|
"first_diff_method": (first_file.get("diff_selector") or {}).get("method") if isinstance(first_file.get("diff_selector"), dict) else None,
|
|
}
|
|
require(changes.get("schema") == "onec_saved_state_changes_list.v1", "changes list must return expected schema", failures)
|
|
require(changes.get("status") in {"changed", "unchanged", "empty"}, "changes list must classify saved-state status", failures)
|
|
require(freshness.get("status") == "live_sql_verified", "changes list must be live SQL verified", failures)
|
|
require(freshness.get("verified_against_sql") is True, "changes list must report SQL verification", failures)
|
|
require(isinstance(counts.get("tables"), int) and counts.get("tables") >= 1, "changes list must include at least one checked table", failures)
|
|
if files:
|
|
require((first_file.get("diff_selector") or {}).get("method") == "metadata.saved_state.diff", "change entries must include diff selectors", failures)
|
|
|
|
contextual = rpc_call(
|
|
endpoint_url,
|
|
"metadata.saved_state.changes.list",
|
|
{
|
|
"base_id": base_id,
|
|
"limit": 10,
|
|
"include_context": True,
|
|
"group_by_context": True,
|
|
"context_limit": 5,
|
|
"timeout_seconds": int(timeout),
|
|
"include_storage": True,
|
|
},
|
|
timeout,
|
|
transport=transport,
|
|
session_id=session_id,
|
|
)
|
|
contextual_freshness = contextual.get("freshness") if isinstance(contextual.get("freshness"), dict) else {}
|
|
contextual_files = contextual.get("files") if isinstance(contextual.get("files"), list) else []
|
|
contextual_groups = contextual.get("groups") if isinstance(contextual.get("groups"), list) else []
|
|
checks["changes_list_context"] = {
|
|
"schema": contextual.get("schema"),
|
|
"status": contextual.get("status"),
|
|
"freshness": contextual_freshness.get("status"),
|
|
"include_context": (contextual.get("query") or {}).get("include_context") if isinstance(contextual.get("query"), dict) else None,
|
|
"group_by_context": (contextual.get("query") or {}).get("group_by_context") if isinstance(contextual.get("query"), dict) else None,
|
|
"context_enrichment": (contextual.get("query") or {}).get("context_enrichment") if isinstance(contextual.get("query"), dict) else None,
|
|
"context_limit": (contextual.get("query") or {}).get("context_limit") if isinstance(contextual.get("query"), dict) else None,
|
|
"files_with_context": sum(1 for item in contextual_files if isinstance(item, dict) and isinstance(item.get("context"), dict)),
|
|
"groups": len(contextual_groups),
|
|
"first_group_files": ((contextual_groups[0].get("counts") or {}).get("files") if contextual_groups and isinstance(contextual_groups[0], dict) and isinstance(contextual_groups[0].get("counts"), dict) else None),
|
|
"first_group_diff_selectors": (
|
|
len((contextual_groups[0].get("selectors") or {}).get("diff") or [])
|
|
if contextual_groups and isinstance(contextual_groups[0], dict) and isinstance(contextual_groups[0].get("selectors"), dict)
|
|
else None
|
|
),
|
|
"first_group_next_actions": (
|
|
len(contextual_groups[0].get("next_actions") or [])
|
|
if contextual_groups and isinstance(contextual_groups[0], dict) and isinstance(contextual_groups[0].get("next_actions"), list)
|
|
else None
|
|
),
|
|
"first_group_recommended_action": (
|
|
(contextual_groups[0].get("recommended_next_action") or {}).get("kind")
|
|
if contextual_groups and isinstance(contextual_groups[0], dict) and isinstance(contextual_groups[0].get("recommended_next_action"), dict)
|
|
else None
|
|
),
|
|
"first_group_action_total": (
|
|
(contextual_groups[0].get("action_summary") or {}).get("total")
|
|
if contextual_groups and isinstance(contextual_groups[0], dict) and isinstance(contextual_groups[0].get("action_summary"), dict)
|
|
else None
|
|
),
|
|
"root_recommended_action": (
|
|
((contextual.get("recommended_next_action") or {}).get("action") or {}).get("kind")
|
|
if isinstance(contextual.get("recommended_next_action"), dict) and isinstance((contextual.get("recommended_next_action") or {}).get("action"), dict)
|
|
else None
|
|
),
|
|
"root_action_total": (
|
|
(contextual.get("action_summary") or {}).get("total")
|
|
if isinstance(contextual.get("action_summary"), dict)
|
|
else None
|
|
),
|
|
}
|
|
require(contextual.get("schema") == "onec_saved_state_changes_list.v1", "contextual changes list must return expected schema", failures)
|
|
require(contextual_freshness.get("status") == "live_sql_verified", "contextual changes list must be live SQL verified", failures)
|
|
require((contextual.get("query") or {}).get("include_context") is True if isinstance(contextual.get("query"), dict) else False, "contextual changes list must echo include_context=true", failures)
|
|
require((contextual.get("query") or {}).get("group_by_context") is True if isinstance(contextual.get("query"), dict) else False, "contextual changes list must echo group_by_context=true", failures)
|
|
if contextual_files:
|
|
require(isinstance(contextual.get("groups"), list), "contextual changes list must include groups when files exist", failures)
|
|
if contextual_groups:
|
|
first_group_selectors = contextual_groups[0].get("selectors") if isinstance(contextual_groups[0], dict) else None
|
|
require(isinstance(first_group_selectors, dict) and isinstance(first_group_selectors.get("diff"), list), "change groups must include diff selectors", failures)
|
|
require(isinstance(contextual_groups[0].get("next_actions"), list), "change groups must include next_actions", failures)
|
|
require(isinstance(contextual_groups[0].get("action_summary"), dict), "change groups must include action_summary", failures)
|
|
require(isinstance(contextual.get("action_summary"), dict), "contextual changes list must include root action_summary", failures)
|
|
require(isinstance(contextual.get("recommended_next_action"), dict), "contextual changes list must include root recommended_next_action", failures)
|
|
|
|
return {
|
|
"schema": "onec_saved_state_changes_smoke.v1",
|
|
"status": "ok" if not failures else "failed",
|
|
"endpoint_url": endpoint_url,
|
|
"transport": transport,
|
|
"base_id": base_id,
|
|
"checks": checks,
|
|
"failures": failures,
|
|
**({"mcp_initialize": initialize_result} if initialize_result and failures else {}),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Smoke deployed metadata.saved_state.changes.list behavior without SQL writes.")
|
|
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
|
parser.add_argument("--mcp-url", default=DEFAULT_MCP_URL)
|
|
parser.add_argument("--transport", choices=("rest", "mcp"), default="rest")
|
|
parser.add_argument("--base-id", default="upo_test")
|
|
parser.add_argument("--timeout", type=float, default=30.0)
|
|
parser.add_argument("--report", type=Path)
|
|
args = parser.parse_args()
|
|
|
|
endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url
|
|
report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport)
|
|
if args.report:
|
|
args.report.parent.mkdir(parents=True, exist_ok=True)
|
|
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
return 0 if report["status"] == "ok" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|