From 21e3e1866b099da25cee6dea062ed1f2478a850f Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 21:24:34 +0800 Subject: [PATCH] Add HTTP record/replay cassette for offline API tests The HTTP client hardcoded its urllib transport, so a flow driving a real API could not be re-run in CI without the live server. Expose a build_call / urllib_transport seam on the client and layer a VCR-style cassette on top: replay returns a recorded response for a matching request (pure, no network), recording is a thin pass-through over a live transport. Wired through facade, executor (AC_http_replay), MCP, and the Script Builder with a headless test batch and EN/Zh docs. --- README.md | 7 ++ README/README_zh-CN.md | 7 ++ README/README_zh-TW.md | 7 ++ .../Eng/doc/new_features/v75_features_doc.rst | 46 ++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v75_features_doc.rst | 41 +++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 3 + .../gui/script_builder/command_schema.py | 13 +++ .../utils/executor/action_executor.py | 15 +++ .../utils/http_cassette/__init__.py | 6 + .../utils/http_cassette/http_cassette.py | 110 ++++++++++++++++++ je_auto_control/utils/http_client/__init__.py | 6 +- .../utils/http_client/http_client.py | 40 +++++-- .../utils/mcp_server/tools/_factories.py | 19 ++- .../utils/mcp_server/tools/_handlers.py | 9 ++ .../headless/test_http_cassette_batch.py | 95 +++++++++++++++ 17 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 docs/source/Eng/doc/new_features/v75_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v75_features_doc.rst create mode 100644 je_auto_control/utils/http_cassette/__init__.py create mode 100644 je_auto_control/utils/http_cassette/http_cassette.py create mode 100644 test/unit_test/headless/test_http_cassette_batch.py diff --git a/README.md b/README.md index c6405442..405eda3c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — HTTP Record & Replay Cassette](#whats-new-2026-06-21--http-record--replay-cassette) - [What's new (2026-06-21) — Bulkhead & Rate-Limit Headers](#whats-new-2026-06-21--bulkhead--rate-limit-headers) - [What's new (2026-06-21) — Streaming Latency Percentiles](#whats-new-2026-06-21--streaming-latency-percentiles) - [What's new (2026-06-21) — Service-Level Objectives (SLO)](#whats-new-2026-06-21--service-level-objectives-slo) @@ -127,6 +128,12 @@ --- +## What's new (2026-06-21) — HTTP Record & Replay Cassette + +Re-run API flows in CI with no live server. Full reference: [`docs/source/Eng/doc/new_features/v75_features_doc.rst`](docs/source/Eng/doc/new_features/v75_features_doc.rst). + +- **`Cassette` / `CassetteMissError`** (`AC_http_replay`): the HTTP client hardcoded its `urllib` transport, so a flow driving a real API couldn't be re-run offline. The client now exposes a `build_call` / `urllib_transport` seam, and this adds a VCR-style cassette — `replay` returns a recorded response for a matching request (pure, no network — the CI-valuable half), `recording_transport` is a thin pass-through over the live transport. Match on `method`/`url` (optionally `body`); `save`/`load` JSON cassettes. Pure-stdlib. + ## What's new (2026-06-21) — Bulkhead & Rate-Limit Headers Cap concurrency, honor server back-off. Full reference: [`docs/source/Eng/doc/new_features/v74_features_doc.rst`](docs/source/Eng/doc/new_features/v74_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index e350bf0b..0fa003b0 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — HTTP 录制与重播卡带](#本次更新-2026-06-21--http-录制与重播卡带) - [本次更新 (2026-06-21) — 隔舱与速率限制标头](#本次更新-2026-06-21--隔舱与速率限制标头) - [本次更新 (2026-06-21) — 流式延迟百分位](#本次更新-2026-06-21--流式延迟百分位) - [本次更新 (2026-06-21) — 服务等级目标(SLO)](#本次更新-2026-06-21--服务等级目标slo) @@ -126,6 +127,12 @@ --- +## 本次更新 (2026-06-21) — HTTP 录制与重播卡带 + +在 CI 中重跑 API 流程,无需在线服务器。完整参考:[`docs/source/Zh/doc/new_features/v75_features_doc.rst`](../docs/source/Zh/doc/new_features/v75_features_doc.rst)。 + +- **`Cassette` / `CassetteMissError`**(`AC_http_replay`):HTTP 客户端把 `urllib` 传输写死,因此驱动真实 API 的流程无法离线重跑。客户端现在开放 `build_call` / `urllib_transport` 接缝,本功能加入 VCR 风格卡带 —— `replay` 为相符请求返回已录制响应(纯粹、不联网,对 CI 最有价值的一半),`recording_transport` 则是在实际传输之上的薄薄转送。可按 `method`/`url`(可加 `body`)匹配;以 JSON `save`/`load` 卡带。纯标准库。 + ## 本次更新 (2026-06-21) — 隔舱与速率限制标头 限制并发、遵守服务器退避。完整参考:[`docs/source/Zh/doc/new_features/v74_features_doc.rst`](../docs/source/Zh/doc/new_features/v74_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index eeb11f68..50e2f4cf 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — HTTP 錄製與重播卡帶](#本次更新-2026-06-21--http-錄製與重播卡帶) - [本次更新 (2026-06-21) — 隔艙與速率限制標頭](#本次更新-2026-06-21--隔艙與速率限制標頭) - [本次更新 (2026-06-21) — 串流延遲百分位](#本次更新-2026-06-21--串流延遲百分位) - [本次更新 (2026-06-21) — 服務等級目標(SLO)](#本次更新-2026-06-21--服務等級目標slo) @@ -126,6 +127,12 @@ --- +## 本次更新 (2026-06-21) — HTTP 錄製與重播卡帶 + +在 CI 中重跑 API 流程,無需線上伺服器。完整參考:[`docs/source/Zh/doc/new_features/v75_features_doc.rst`](../docs/source/Zh/doc/new_features/v75_features_doc.rst)。 + +- **`Cassette` / `CassetteMissError`**(`AC_http_replay`):HTTP 用戶端把 `urllib` 傳輸寫死,因此驅動真實 API 的流程無法離線重跑。用戶端現在開放 `build_call` / `urllib_transport` 接縫,本功能加入 VCR 風格卡帶 —— `replay` 為相符請求回傳已錄製回應(純粹、不連網,對 CI 最有價值的一半),`recording_transport` 則是在實際傳輸之上的薄薄轉送。可依 `method`/`url`(可加 `body`)比對;以 JSON `save`/`load` 卡帶。純標準函式庫。 + ## 本次更新 (2026-06-21) — 隔艙與速率限制標頭 限制並行、遵守伺服器退避。完整參考:[`docs/source/Zh/doc/new_features/v74_features_doc.rst`](../docs/source/Zh/doc/new_features/v74_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v75_features_doc.rst b/docs/source/Eng/doc/new_features/v75_features_doc.rst new file mode 100644 index 00000000..512d5087 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v75_features_doc.rst @@ -0,0 +1,46 @@ +HTTP Record & Replay Cassette +============================= + +The HTTP client hardcoded its ``urllib`` transport, so a flow that drives a +real API could not be re-run in CI without the live server reachable. The +client now exposes a ``build_call`` / ``urllib_transport`` seam, and this layer +adds a VCR-style **cassette**: replay returns a recorded response for a +matching request (pure, no network — the CI-valuable half), while recording is +a thin pass-through over a live transport. + +Pure standard library (``json``); imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import Cassette, CassetteMissError + from je_auto_control.utils.http_client import build_call, urllib_transport + + # Record once (against the live server), then save: + cassette = Cassette() + transport = cassette.recording_transport(urllib_transport) + transport(build_call("https://api.example.com/users/1", "GET")) + cassette.save("users.cassette.json") + + # Replay forever — deterministic, offline: + cassette = Cassette.load("users.cassette.json") + response = cassette.replay(build_call("https://api.example.com/users/1", "GET")) + assert response["status"] == 200 + +``build_call`` turns request parameters into a plain dict (url, method, headers, +body, timeout) without touching the network; ``urllib_transport`` performs it. +``Cassette.record`` stores one request/response pair; ``replay`` returns the +recorded response for a request matching ``match_on`` (``("method", "url")`` by +default, optionally ``"body"``) and raises ``CassetteMissError`` when nothing +matches. ``replay_transport`` / ``recording_transport`` return drop-in +transports so existing call sites swap live traffic for the cassette unchanged. + +Executor command +---------------- + +``AC_http_replay`` takes a ``cassette`` (interactions list or ``{interactions}``, +JSON string accepted), a ``url`` and optional ``method``, and returns the +recorded ``{response}`` with no network access. It is exposed as the MCP tool +``ac_http_replay`` and as a Script Builder command under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 9b1c3b06..dc6401b5 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -97,6 +97,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v72_features_doc doc/new_features/v73_features_doc doc/new_features/v74_features_doc + doc/new_features/v75_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v75_features_doc.rst b/docs/source/Zh/doc/new_features/v75_features_doc.rst new file mode 100644 index 00000000..8a7f9b62 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v75_features_doc.rst @@ -0,0 +1,41 @@ +HTTP 錄製與重播卡帶 +================== + +HTTP 用戶端把 ``urllib`` 傳輸寫死,因此一個驅動真實 API 的流程,若沒有可連線的線上伺服器便無法在 +CI 重跑。用戶端現在開放 ``build_call`` / ``urllib_transport`` 接縫,本層在其上加入 VCR 風格的 +**卡帶**:重播會為相符的請求回傳已錄製的回應(純粹、不連網 —— 對 CI 最有價值的一半),而錄製則是 +在實際傳輸之上的薄薄一層轉送。 + +純標準函式庫(``json``);不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import Cassette, CassetteMissError + from je_auto_control.utils.http_client import build_call, urllib_transport + + # 對線上伺服器錄製一次,然後存檔: + cassette = Cassette() + transport = cassette.recording_transport(urllib_transport) + transport(build_call("https://api.example.com/users/1", "GET")) + cassette.save("users.cassette.json") + + # 之後永遠重播 —— 具決定性、離線: + cassette = Cassette.load("users.cassette.json") + response = cassette.replay(build_call("https://api.example.com/users/1", "GET")) + assert response["status"] == 200 + +``build_call`` 把請求參數轉成純 dict(url、method、headers、body、timeout)而不碰網路; +``urllib_transport`` 負責實際發送。``Cassette.record`` 儲存一組請求/回應;``replay`` 為符合 +``match_on``(預設 ``("method", "url")``,可加 ``"body"``)的請求回傳已錄製的回應,找不到時拋出 +``CassetteMissError``。``replay_transport`` / ``recording_transport`` 回傳可直接替換的傳輸,讓既有 +呼叫端在不變動下把實際流量換成卡帶。 + +執行器命令 +---------- + +``AC_http_replay`` 接受 ``cassette``(interactions 清單或 ``{interactions}``,可為 JSON 字串)、 +``url`` 與選用的 ``method``,在不連網的情況下回傳已錄製的 ``{response}``。它以 MCP 工具 +``ac_http_replay`` 以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index d2c21a42..31737bc1 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -97,6 +97,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v72_features_doc doc/new_features/v73_features_doc doc/new_features/v74_features_doc + doc/new_features/v75_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index bdde04a7..b88fa3ac 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -375,6 +375,8 @@ from je_auto_control.utils.bulkhead import ( Bulkhead, BulkheadFullError, next_delay, parse_ratelimit, parse_retry_after, ) +# HTTP record/replay cassette (deterministic offline API tests) +from je_auto_control.utils.http_cassette import Cassette, CassetteMissError # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -874,6 +876,7 @@ def start_autocontrol_gui(*args, **kwargs): "LatencyDigest", "exact_percentiles", "Bulkhead", "BulkheadFullError", "next_delay", "parse_ratelimit", "parse_retry_after", + "Cassette", "CassetteMissError", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 293df940..5ade50d4 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1593,6 +1593,19 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Server-advised wait (seconds) from an HTTP response; {delay}.", )) + specs.append(CommandSpec( + "AC_http_replay", "Data", "HTTP Cassette: Replay", + fields=( + FieldSpec("cassette", FieldType.STRING, + placeholder='{"interactions": [{"request": {...}, ' + '"response": {...}}]}'), + FieldSpec("url", FieldType.STRING, + placeholder="https://api.example.com/users/1"), + FieldSpec("method", FieldType.STRING, optional=True, + placeholder="GET"), + ), + description="Replay a recorded HTTP response from a cassette (no network).", + )) specs.append(CommandSpec( "AC_rate_limit", "Flow", "Rate Limit (Token Bucket)", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index dc113258..3b23bbf8 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2957,6 +2957,20 @@ def _retry_after(response: Any) -> Dict[str, Any]: return {"delay": next_delay(response)} +def _http_replay(cassette: Any, url: str, + method: str = "GET") -> Dict[str, Any]: + """Adapter: replay a recorded HTTP response from a cassette (no network).""" + import json + from je_auto_control.utils.http_cassette import Cassette + if isinstance(cassette, str): + cassette = json.loads(cassette) + interactions = (cassette.get("interactions", []) + if isinstance(cassette, dict) else cassette) + response = Cassette(interactions).replay( + {"method": str(method).upper(), "url": url}) + return {"response": response} + + def _percentiles(samples: Any, qs: Any = None) -> Dict[str, Any]: """Adapter: exact percentiles of a numeric sample list (or JSON string).""" import json @@ -4032,6 +4046,7 @@ def __init__(self): "AC_percentiles": _percentiles, "AC_bulkhead_run": _bulkhead_run, "AC_retry_after": _retry_after, + "AC_http_replay": _http_replay, "AC_unified_diff": _unified_diff, "AC_apply_unified": _apply_unified, "AC_three_way_merge": _three_way_merge, diff --git a/je_auto_control/utils/http_cassette/__init__.py b/je_auto_control/utils/http_cassette/__init__.py new file mode 100644 index 00000000..7d4c7231 --- /dev/null +++ b/je_auto_control/utils/http_cassette/__init__.py @@ -0,0 +1,6 @@ +"""Record / replay HTTP interactions for deterministic offline API tests.""" +from je_auto_control.utils.http_cassette.http_cassette import ( + Cassette, CassetteMissError, +) + +__all__ = ["Cassette", "CassetteMissError"] diff --git a/je_auto_control/utils/http_cassette/http_cassette.py b/je_auto_control/utils/http_cassette/http_cassette.py new file mode 100644 index 00000000..443c62cc --- /dev/null +++ b/je_auto_control/utils/http_cassette/http_cassette.py @@ -0,0 +1,110 @@ +"""Record and replay HTTP interactions for deterministic, offline API tests. + +The HTTP client hardcoded its ``urllib`` transport, so a flow that drives an +API could not be re-run without the live server. The client now exposes a +``build_call`` / ``urllib_transport`` seam; this module layers a VCR-style +cassette on top: **replay** returns a recorded response for a matching request +(pure, no network — the CI-valuable half), while **recording** is a thin +pass-through over a live transport. + +Pure standard library (``json``); imports no ``PySide6``. +""" +import json +from pathlib import Path +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence + +from je_auto_control.utils.exception.exceptions import AutoControlException + +Transport = Callable[[Mapping[str, Any]], Dict[str, Any]] + + +class CassetteMissError(AutoControlException): + """No recorded interaction matched the request during replay.""" + + +def _decode_body(body: Any) -> Optional[str]: + if body is None: + return None + if isinstance(body, bytes): + return body.decode("utf-8", errors="replace") + return str(body) + + +def _request_view(call: Mapping[str, Any]) -> Dict[str, Any]: + return {"method": str(call.get("method", "GET")).upper(), + "url": call.get("url"), + "headers": dict(call.get("headers") or {}), + "body": _decode_body(call.get("body"))} + + +def _matches(recorded: Mapping[str, Any], call: Mapping[str, Any], + match_on: Sequence[str]) -> bool: + view = _request_view(call) + for field in match_on: + if field == "method": + if recorded.get("method") != view["method"]: + return False + elif field == "url": + if recorded.get("url") != view["url"]: + return False + elif field == "body": + if recorded.get("body") != view["body"]: + return False + return True + + +class Cassette: + """A set of recorded request/response interactions (VCR-style).""" + + def __init__(self, + interactions: Optional[Sequence[Mapping[str, Any]]] = None) -> None: + self._interactions: List[Dict[str, Any]] = [ + dict(item) for item in (interactions or [])] + + @property + def interactions(self) -> List[Dict[str, Any]]: + """The recorded interactions.""" + return self._interactions + + @classmethod + def load(cls, path: str) -> "Cassette": + """Load a cassette from a JSON file.""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + return cls(data.get("interactions", [])) + + def save(self, path: str) -> str: + """Write the cassette to ``path`` as JSON; return the path.""" + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps( + {"version": 1, "interactions": self._interactions}, indent=2), + encoding="utf-8") + return str(out) + + def record(self, call: Mapping[str, Any], + response: Mapping[str, Any]) -> None: + """Append one request/response interaction.""" + self._interactions.append({"request": _request_view(call), + "response": dict(response)}) + + def replay(self, call: Mapping[str, Any], *, + match_on: Sequence[str] = ("method", "url")) -> Dict[str, Any]: + """Return the recorded response for a matching ``call`` (no network).""" + for interaction in self._interactions: + if _matches(interaction.get("request", {}), call, match_on): + return dict(interaction["response"]) + raise CassetteMissError( + f"no recorded interaction for {call.get('method')} {call.get('url')}") + + def replay_transport(self, *, + match_on: Sequence[str] = ("method", "url")) -> Transport: + """Return a transport that replays from this cassette.""" + return lambda call: self.replay(call, match_on=match_on) + + def recording_transport(self, inner: Transport) -> Transport: + """Return a transport that records ``inner``'s live responses.""" + def transport(call: Mapping[str, Any]) -> Dict[str, Any]: + response = inner(call) + self.record(call, response) + return response + return transport diff --git a/je_auto_control/utils/http_client/__init__.py b/je_auto_control/utils/http_client/__init__.py index 5ea5dcf2..a6a9c647 100644 --- a/je_auto_control/utils/http_client/__init__.py +++ b/je_auto_control/utils/http_client/__init__.py @@ -1,4 +1,6 @@ """Dependency-free HTTP(S) client for AutoControl action steps.""" -from je_auto_control.utils.http_client.http_client import http_request +from je_auto_control.utils.http_client.http_client import ( + build_call, http_request, urllib_transport, +) -__all__ = ["http_request"] +__all__ = ["build_call", "http_request", "urllib_transport"] diff --git a/je_auto_control/utils/http_client/http_client.py b/je_auto_control/utils/http_client/http_client.py index 1af8527c..74d8887f 100644 --- a/je_auto_control/utils/http_client/http_client.py +++ b/je_auto_control/utils/http_client/http_client.py @@ -78,6 +78,34 @@ def _read_response(response: Any) -> Dict[str, Any]: } +def build_call(url: str, method: str = "GET", + headers: Optional[Mapping[str, Any]] = None, + json_body: Any = None, data: Any = None, + auth: Optional[Mapping[str, Any]] = None, + timeout: float = _DEFAULT_TIMEOUT) -> Dict[str, Any]: + """Build a transport ``call`` dict (url/method/headers/body/timeout).""" + _validate_url(url) + return { + "url": url, "method": str(method).upper(), + "headers": _build_headers(headers, json_body, auth), + "body": _encode_body(json_body, data), "timeout": float(timeout), + } + + +def urllib_transport(call: Mapping[str, Any]) -> Dict[str, Any]: + """The default live transport: perform a ``call`` with ``urllib``.""" + request = urllib.request.Request( + call["url"], data=call.get("body"), method=call["method"], + headers=dict(call.get("headers") or {})) + try: + with urllib.request.urlopen( # nosec B310 — scheme allow-listed + request, timeout=float(call.get("timeout", _DEFAULT_TIMEOUT))) \ + as response: + return _read_response(response) + except urllib.error.HTTPError as error: + return _read_response(error) + + def http_request(url: str, method: str = "GET", headers: Optional[Mapping[str, Any]] = None, json_body: Any = None, data: Any = None, @@ -92,15 +120,7 @@ def http_request(url: str, method: str = "GET", responses are returned (with their body) rather than raised, so callers can assert on status codes. """ - _validate_url(url) + call = build_call(url, method, headers, json_body, data, auth, timeout) from je_auto_control.utils.egress.egress_policy import get_egress_policy get_egress_policy().check(url) # allow-all unless an operator locked it down - request = urllib.request.Request( - url, data=_encode_body(json_body, data), method=str(method).upper(), - headers=_build_headers(headers, json_body, auth)) - try: - with urllib.request.urlopen( # nosec B310 — scheme allow-listed - request, timeout=float(timeout)) as response: - return _read_response(response) - except urllib.error.HTTPError as error: - return _read_response(error) + return urllib_transport(call) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 1b0a28d7..aae4b449 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,23 @@ def rate_limit_tools() -> List[MCPTool]: ] +def http_cassette_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_http_replay", + description=("Replay a recorded HTTP response from a 'cassette' " + "(interactions list or {interactions}) for a 'url' / " + "'method' — no network. Returns {response}."), + input_schema=schema( + {"cassette": {"type": "object"}, "url": {"type": "string"}, + "method": {"type": "string"}}, + ["cassette", "url"]), + handler=h.http_replay, + annotations=READ_ONLY, + ), + ] + + def bulkhead_tools() -> List[MCPTool]: return [ MCPTool( @@ -4860,7 +4877,7 @@ def media_assert_tools() -> List[MCPTool]: license_policy_tools, jwt_tools, rate_limit_tools, json_patch_tools, search_index_tools, stats_tools, recurrence_tools, text_diff_tools, feature_flag_tools, provenance_tools, json_contract_tools, chaos_tools, - slo_tools, percentiles_tools, bulkhead_tools, + slo_tools, percentiles_tools, bulkhead_tools, http_cassette_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 062bb385..e081e86d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1707,6 +1707,15 @@ def retry_after(response): return {"delay": next_delay(response)} +def http_replay(cassette, url, method="GET"): + from je_auto_control.utils.http_cassette import Cassette + interactions = (cassette.get("interactions", []) + if isinstance(cassette, dict) else cassette) + response = Cassette(interactions).replay( + {"method": str(method).upper(), "url": url}) + return {"response": response} + + def build_provenance(paths, builder_id="je_auto_control"): from je_auto_control.utils.provenance import build_provenance, subject_for subjects = [subject_for(path) for path in paths] diff --git a/test/unit_test/headless/test_http_cassette_batch.py b/test/unit_test/headless/test_http_cassette_batch.py new file mode 100644 index 00000000..7aac58a6 --- /dev/null +++ b/test/unit_test/headless/test_http_cassette_batch.py @@ -0,0 +1,95 @@ +"""Headless tests for HTTP record/replay cassette. Pure stdlib, no network.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.http_cassette import Cassette, CassetteMissError +from je_auto_control.utils.http_client import build_call + + +def _fake_inner(call): + return {"status": 200, "ok": True, "headers": {}, "text": '{"id": 1}', + "json": {"id": 1}, "url": call["url"]} + + +def test_record_then_replay(): + cassette = Cassette() + call = build_call("https://api.example.com/users/1", "GET") + cassette.recording_transport(_fake_inner)(call) + assert len(cassette.interactions) == 1 + replayed = cassette.replay_transport()( + build_call("https://api.example.com/users/1", "GET")) + assert replayed["json"] == {"id": 1} + + +def test_replay_miss_raises(): + cassette = Cassette() + cassette.recording_transport(_fake_inner)( + build_call("https://api.example.com/a", "GET")) + with pytest.raises(CassetteMissError): + cassette.replay_transport()(build_call("https://api.example.com/b", "GET")) + + +def test_save_and_load_round_trip(tmp_path): + cassette = Cassette() + cassette.recording_transport(_fake_inner)( + build_call("https://api.example.com/x", "GET")) + path = str(tmp_path / "cas.json") + cassette.save(path) + loaded = Cassette.load(path) + assert loaded.replay( + build_call("https://api.example.com/x", "GET"))["status"] == 200 + + +def test_match_on_body(): + cassette = Cassette() + cassette.recording_transport(_fake_inner)( + build_call("https://api.example.com/x", "POST", json_body={"a": 1})) + transport = cassette.replay_transport(match_on=("method", "url", "body")) + assert transport(build_call("https://api.example.com/x", "POST", + json_body={"a": 1}))["status"] == 200 + with pytest.raises(CassetteMissError): + transport(build_call("https://api.example.com/x", "POST", + json_body={"a": 2})) + + +def test_recording_passes_through_response(): + cassette = Cassette() + response = cassette.recording_transport(_fake_inner)( + build_call("https://api.example.com/u", "GET")) + assert response["json"] == {"id": 1} # returns the live response + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + cassette = {"interactions": [{ + "request": {"method": "GET", "url": "https://api.example.com/p", + "headers": {}, "body": None}, + "response": {"status": 200, "ok": True, "json": {"ok": True}}}]} + rec = ac.execute_action([[ + "AC_http_replay", + {"cassette": json.dumps(cassette), "url": "https://api.example.com/p"}, + ]]) + response = next(v for v in rec.values() if isinstance(v, dict))["response"] + assert response["status"] == 200 and response["json"] == {"ok": True} + + +def test_http_request_still_works_after_refactor(): + # the transport-seam refactor must not change http_request's surface + assert callable(ac.http_request) + + +def test_wiring(): + assert "AC_http_replay" in ac.executor.known_commands() + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + assert "ac_http_replay" in {t.name for t in build_default_tool_registry()} + from je_auto_control.gui.script_builder.command_schema import _build_specs + assert "AC_http_replay" in {s.command for s in _build_specs()} + + +def test_facade_exports(): + for attr in ("Cassette", "CassetteMissError"): + assert hasattr(ac, attr) + assert attr in ac.__all__