382 lines
19 KiB
Python
382 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
|
|
DEFAULT_ADAPTER_URL = "http://docker-gpu.cin.su:8011"
|
|
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
|
|
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
|
|
|
|
|
|
for stream in (sys.stdout, sys.stderr):
|
|
if hasattr(stream, "reconfigure"):
|
|
stream.reconfigure(encoding="utf-8")
|
|
|
|
|
|
def rpc(adapter_url: str, method: str, payload: dict[str, Any], *, timeout: int = 60) -> dict[str, Any]:
|
|
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
|
|
service_token = os.environ.get("ONEC_ADAPTER_SERVICE_TOKEN", "").strip()
|
|
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
if service_token:
|
|
headers["Authorization"] = f"Bearer {service_token}"
|
|
request = urllib.request.Request(
|
|
adapter_url.rstrip("/") + "/rpc",
|
|
data=body,
|
|
headers=headers,
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
return json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"{method} HTTP {exc.code}: {detail}") from exc
|
|
|
|
|
|
def post_json(url: str, payload: dict[str, Any], *, timeout: int, 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"),
|
|
headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **(headers or {})},
|
|
method="POST",
|
|
)
|
|
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 mcp_initialize(mcp_url: str, timeout: int) -> tuple[str | None, dict[str, Any]]:
|
|
body = {
|
|
"jsonrpc": "2.0",
|
|
"id": "code-write-saved-state-initialize",
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-06-18",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "smoke_1c_code_write_saved_state", "version": "1"},
|
|
},
|
|
}
|
|
try:
|
|
headers, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout)
|
|
except urllib.error.HTTPError as exc:
|
|
raw = exc.read().decode("utf-8", errors="replace")
|
|
return None, {"status": "error", "error": "mcp_initialize_http_error", "http_status": exc.code, "response": raw}
|
|
except urllib.error.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: int, session_id: str | None) -> dict[str, Any]:
|
|
headers: dict[str, str] = {}
|
|
if session_id:
|
|
headers["Mcp-Session-Id"] = session_id
|
|
body = {
|
|
"jsonrpc": "2.0",
|
|
"id": f"code-write-saved-state-{method}",
|
|
"method": "tools/call",
|
|
"params": {"name": "onec_request", "arguments": {"method": method, "payload": payload}},
|
|
}
|
|
try:
|
|
_headers, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers=headers)
|
|
except urllib.error.HTTPError as exc:
|
|
raw = exc.read().decode("utf-8", errors="replace")
|
|
return {"status": "error", "error": "mcp_http_error", "http_status": exc.code, "response": raw}
|
|
except urllib.error.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], *, transport: str, timeout: int, session_id: str | None) -> dict[str, Any]:
|
|
if transport == "mcp":
|
|
payload = {**payload, "mcp_sync": True}
|
|
return mcp_rpc(endpoint_url, method, payload, timeout=timeout, session_id=session_id)
|
|
return rpc(endpoint_url, method, payload, timeout=timeout)
|
|
|
|
|
|
def require(condition: bool, message: str, failures: list[str]) -> None:
|
|
if not condition:
|
|
failures.append(message)
|
|
|
|
|
|
def require_saved_state_write(result: dict[str, Any], label: str, failures: list[str]) -> None:
|
|
write_mode = result.get("write_mode") if isinstance(result.get("write_mode"), dict) else {}
|
|
require(result.get("status") == "applied", f"{label} must return status=applied", failures)
|
|
require(result.get("applied") is True, f"{label} must set applied=true", failures)
|
|
require(write_mode.get("target") == "saved_state", f"{label} must target saved_state", failures)
|
|
require(write_mode.get("activation_state") == "not_activated", f"{label} must not activate runtime state", failures)
|
|
|
|
|
|
def first_unique_fragment(text: str) -> str:
|
|
for line in text.splitlines():
|
|
candidate = line.strip()
|
|
if candidate and text.count(candidate) == 1:
|
|
return candidate
|
|
return text
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Smoke-test agent-facing 1C code.write saved-state workflow.")
|
|
parser.add_argument("--transport", choices=("rest", "mcp"), default="rest")
|
|
parser.add_argument("--adapter-url", default=DEFAULT_ADAPTER_URL)
|
|
parser.add_argument("--mcp-url", default=DEFAULT_MCP_URL)
|
|
parser.add_argument("--base-id", default="upo_test")
|
|
parser.add_argument("--extension", default="test2")
|
|
parser.add_argument("--object-type", default="CommonForm")
|
|
parser.add_argument("--object-name", default="t_Форма")
|
|
parser.add_argument("--routine-name", default="ЗаменаДомена")
|
|
parser.add_argument("--allow-missing-target", action="store_true")
|
|
parser.add_argument("--timeout", type=int, default=60)
|
|
parser.add_argument("--report", type=str)
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
endpoint_url = args.mcp_url if args.transport == "mcp" else args.adapter_url
|
|
|
|
selector = {
|
|
"base_id": args.base_id,
|
|
"object_type": args.object_type,
|
|
"object_name": args.object_name,
|
|
"routine_name": args.routine_name,
|
|
"include_text": True,
|
|
"max_chars": 4000,
|
|
}
|
|
failures: list[str] = []
|
|
steps: list[dict[str, Any]] = []
|
|
session_id: str | None = None
|
|
initialize_result: dict[str, Any] | None = None
|
|
|
|
if args.transport == "mcp":
|
|
session_id, initialize_result = mcp_initialize(endpoint_url, args.timeout)
|
|
contract_version = (((initialize_result.get("result") or {}).get("serverInfo") or {}).get("contract_version") if isinstance(initialize_result.get("result"), dict) else None)
|
|
steps.append({"name": "mcp.initialize", "status": "ok" if session_id and contract_version == EXPECTED_CONTRACT_VERSION else "error", "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)
|
|
|
|
before = rpc_call(endpoint_url, "code.read", {**selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
|
|
steps.append({"name": "code.read working before", "status": before.get("status")})
|
|
if args.allow_missing_target and before.get("status") != "ok":
|
|
report = {
|
|
"schema": "onec_code_write_saved_state_smoke.v1",
|
|
"status": "skipped_missing_target",
|
|
"skipped": True,
|
|
"endpoint_url": endpoint_url,
|
|
"transport": args.transport,
|
|
"base_id": args.base_id,
|
|
"target": {"object_type": args.object_type, "object_name": args.object_name, "routine_name": args.routine_name},
|
|
"steps": steps,
|
|
"failures": [],
|
|
**({"mcp_initialize": initialize_result} if initialize_result and args.transport == "mcp" else {}),
|
|
}
|
|
if args.report:
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
|
|
with open(args.report, "w", encoding="utf-8") as handle:
|
|
json.dump(report, handle, ensure_ascii=False, indent=2)
|
|
if args.json:
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
else:
|
|
print(f"1C code.write saved-state smoke: {report['status']}")
|
|
return 0
|
|
require(before.get("status") == "ok", "working code.read before write must be ok", failures)
|
|
routine_text = str(before.get("text") or "")
|
|
require(bool(routine_text.strip()), "working code.read must return routine text", failures)
|
|
|
|
write = rpc_call(
|
|
endpoint_url,
|
|
"code.write",
|
|
{
|
|
"base_id": args.base_id,
|
|
"object_type": args.object_type,
|
|
"object_name": args.object_name,
|
|
"routine_name": args.routine_name,
|
|
"routine_text": routine_text,
|
|
"mode": "apply",
|
|
},
|
|
transport=args.transport,
|
|
timeout=args.timeout,
|
|
session_id=session_id,
|
|
)
|
|
steps.append(
|
|
{
|
|
"name": "code.write apply",
|
|
"status": write.get("status"),
|
|
"applied": write.get("applied"),
|
|
"write_mode": write.get("write_mode"),
|
|
}
|
|
)
|
|
write_mode = write.get("write_mode") if isinstance(write.get("write_mode"), dict) else {}
|
|
require_saved_state_write(write, "code.write routine apply", failures)
|
|
|
|
after = rpc_call(endpoint_url, "code.read", {**selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
|
|
steps.append({"name": "code.read working after", "status": after.get("status"), "current_state": after.get("current_state")})
|
|
require(after.get("status") == "ok", "working code.read after write must be ok", failures)
|
|
require(after.get("text") == routine_text, "no-op code.write must preserve routine text", failures)
|
|
current_state = after.get("current_state") if isinstance(after.get("current_state"), dict) else {}
|
|
require(current_state.get("source") == "saved_state", "working code.read after write must read saved_state", failures)
|
|
|
|
fragment = first_unique_fragment(routine_text)
|
|
fragment_write = rpc_call(
|
|
endpoint_url,
|
|
"code.write",
|
|
{
|
|
"base_id": args.base_id,
|
|
"object_type": args.object_type,
|
|
"object_name": args.object_name,
|
|
"routine_name": args.routine_name,
|
|
"old": fragment,
|
|
"new": fragment,
|
|
"mode": "apply",
|
|
},
|
|
transport=args.transport,
|
|
timeout=args.timeout,
|
|
session_id=session_id,
|
|
)
|
|
steps.append(
|
|
{
|
|
"name": "code.write fragment apply",
|
|
"status": fragment_write.get("status"),
|
|
"applied": fragment_write.get("applied"),
|
|
"write_mode": fragment_write.get("write_mode"),
|
|
}
|
|
)
|
|
require_saved_state_write(fragment_write, "code.write fragment apply", failures)
|
|
after_fragment = rpc_call(endpoint_url, "code.read", {**selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
|
|
steps.append({"name": "code.read working after fragment", "status": after_fragment.get("status"), "current_state": after_fragment.get("current_state")})
|
|
require(after_fragment.get("status") == "ok", "working code.read after fragment write must be ok", failures)
|
|
require(after_fragment.get("text") == routine_text, "no-op fragment code.write must preserve routine text", failures)
|
|
|
|
module_selector = {key: value for key, value in selector.items() if key != "routine_name"}
|
|
module_selector["max_chars"] = 200000
|
|
module_before = rpc_call(endpoint_url, "code.read", {**module_selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
|
|
module_text = str(module_before.get("text") or "")
|
|
steps.append({"name": "code.read full module before", "status": module_before.get("status"), "current_state": module_before.get("current_state"), "has_text": bool(module_text.strip())})
|
|
require(module_before.get("status") == "ok", "working full module code.read before module write must be ok", failures)
|
|
require(bool(module_text.strip()), "working full module code.read must return module text", failures)
|
|
require("///----" not in module_text, "working full module code.read must hide the saved form module container marker", failures)
|
|
module_write = rpc_call(
|
|
endpoint_url,
|
|
"code.write",
|
|
{
|
|
"base_id": args.base_id,
|
|
"object_type": args.object_type,
|
|
"object_name": args.object_name,
|
|
"module_text": module_text,
|
|
"mode": "apply",
|
|
},
|
|
transport=args.transport,
|
|
timeout=args.timeout,
|
|
session_id=session_id,
|
|
)
|
|
steps.append(
|
|
{
|
|
"name": "code.write full module apply",
|
|
"status": module_write.get("status"),
|
|
"applied": module_write.get("applied"),
|
|
"write_mode": module_write.get("write_mode"),
|
|
}
|
|
)
|
|
require_saved_state_write(module_write, "code.write full module apply", failures)
|
|
module_after = rpc_call(endpoint_url, "code.read", {**module_selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
|
|
steps.append({"name": "code.read full module after", "status": module_after.get("status"), "current_state": module_after.get("current_state")})
|
|
require(module_after.get("status") == "ok", "working full module code.read after module write must be ok", failures)
|
|
require(module_after.get("text") == module_text, "no-op full module code.write must preserve module text", failures)
|
|
require("///----" not in str(module_after.get("text") or ""), "working full module code.read after write must hide marker", failures)
|
|
|
|
both = rpc_call(endpoint_url, "code.read", {**selector, "state": "both"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
|
|
steps.append({"name": "code.read both", "status": both.get("status"), "comparison": both.get("comparison"), "text_source": both.get("text_source")})
|
|
require(both.get("status") == "ok", "code.read state=both must be ok", failures)
|
|
require(both.get("text_source") == "saved_state", "state=both effective text must come from saved_state when present", failures)
|
|
|
|
found = rpc_call(
|
|
endpoint_url,
|
|
"extension.objects.find",
|
|
{
|
|
"base_id": args.base_id,
|
|
"extension": args.extension,
|
|
"object_type": args.object_type,
|
|
"query": args.object_name,
|
|
"state": "working",
|
|
"limit": 20,
|
|
},
|
|
transport=args.transport,
|
|
timeout=args.timeout,
|
|
session_id=session_id,
|
|
)
|
|
objects = [item for item in found.get("objects") or [] if isinstance(item, dict) and item.get("name") == args.object_name]
|
|
steps.append({"name": "extension.objects.find working", "status": found.get("status"), "matches": len(objects)})
|
|
require(bool(objects), "working extension.objects.find must see target form", failures)
|
|
require(str((objects[0].get("activation_state") if objects else "") or "").startswith("saved_"), "target form must be marked as saved-state in working view", failures)
|
|
|
|
saved_forms = rpc_call(
|
|
endpoint_url,
|
|
"metadata.saved_state.forms.search",
|
|
{
|
|
"base_id": args.base_id,
|
|
"extension": args.extension,
|
|
"form": args.object_name,
|
|
"limit": 20,
|
|
},
|
|
transport=args.transport,
|
|
timeout=args.timeout,
|
|
session_id=session_id,
|
|
)
|
|
saved_form_matches = [
|
|
item
|
|
for item in saved_forms.get("forms") or []
|
|
if isinstance(item, dict) and item.get("name") == args.object_name and item.get("table") in {"ConfigSave", "ConfigCASSave"}
|
|
]
|
|
steps.append({"name": "metadata.saved_state.forms.search", "status": saved_forms.get("status"), "matches": len(saved_form_matches)})
|
|
require(saved_forms.get("status") == "ok", "saved-state form search must be ok after code.write", failures)
|
|
require(bool(saved_form_matches), "saved-state form search must find the target form after code.write", failures)
|
|
if saved_form_matches:
|
|
counts = saved_form_matches[0].get("counts") if isinstance(saved_form_matches[0].get("counts"), dict) else {}
|
|
require(int(counts.get("items_total") or 0) > 0, "saved-state form structure must have decoded items after code.write", failures)
|
|
|
|
report = {
|
|
"schema": "onec_code_write_saved_state_smoke.v1",
|
|
"status": "failed" if failures else "ok",
|
|
"endpoint_url": endpoint_url,
|
|
"transport": args.transport,
|
|
"base_id": args.base_id,
|
|
"target": {"object_type": args.object_type, "object_name": args.object_name, "routine_name": args.routine_name},
|
|
"steps": steps,
|
|
"failures": failures,
|
|
**({"mcp_initialize": initialize_result} if initialize_result and args.transport == "mcp" and failures else {}),
|
|
}
|
|
if args.report:
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
|
|
with open(args.report, "w", encoding="utf-8") as handle:
|
|
json.dump(report, handle, ensure_ascii=False, indent=2)
|
|
if args.json:
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
else:
|
|
print(f"1C code.write saved-state smoke: {report['status']}")
|
|
for step in steps:
|
|
print(f"- {step['name']}: {step.get('status')}")
|
|
for failure in failures:
|
|
print(f"FAIL: {failure}", file=sys.stderr)
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|