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 is_optional_write_gate_block(result: dict[str, Any]) -> bool: return result.get("status") == "blocked" and result.get("error") in { "needs_repository_lock", "support_gate_blocked", } 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( "--allow-blocked-write-gate", action="store_true", help="Skip successfully when the target exists but repository/support policy blocks the first write.", ) 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"), } ) if args.allow_blocked_write_gate and is_optional_write_gate_block(write): report = { "schema": "onec_code_write_saved_state_smoke.v1", "status": "skipped_write_gate", "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, "write_gate": { "status": write.get("status"), "error": write.get("error"), "diagnostics": write.get("diagnostics"), }, "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 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())