diff --git a/README.md b/README.md index f124a329..91fa4335 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-22) — Cookie Jar (HTTP Session Carry)](#whats-new-2026-06-22--cookie-jar-http-session-carry) - [What's new (2026-06-22) — HTTP Content Negotiation & Decompression](#whats-new-2026-06-22--http-content-negotiation--decompression) - [What's new (2026-06-22) — multipart/form-data Build & Parse](#whats-new-2026-06-22--multipartform-data-build--parse) - [What's new (2026-06-22) — Secret Redaction for Config & Logs](#whats-new-2026-06-22--secret-redaction-for-config--logs) @@ -143,6 +144,12 @@ --- +## What's new (2026-06-22) — Cookie Jar (HTTP Session Carry) + +Carry a session across HTTP calls. Full reference: [`docs/source/Eng/doc/new_features/v91_features_doc.rst`](docs/source/Eng/doc/new_features/v91_features_doc.rst). + +- **`CookieJar` / `parse_set_cookie`** (`AC_cookie_header`, `AC_parse_set_cookie`): `http_request` is stateless — no session cookies persisted across calls, so a login-then-call flow couldn't carry a session headlessly. This parses `Set-Cookie` headers into a jar, builds the `Cookie` request header, and saves/loads the jar as JSON (cookies cleared on `Max-Age<=0`/empty). Pure-stdlib, deterministic. + ## What's new (2026-06-22) — HTTP Content Negotiation & Decompression Build `Accept` headers and decode gzip/deflate. Full reference: [`docs/source/Eng/doc/new_features/v90_features_doc.rst`](docs/source/Eng/doc/new_features/v90_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 19bde038..7317be85 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-22) — Cookie Jar(HTTP 会话携带)](#本次更新-2026-06-22--cookie-jarhttp-会话携带) - [本次更新 (2026-06-22) — HTTP 内容协商与解压](#本次更新-2026-06-22--http-内容协商与解压) - [本次更新 (2026-06-22) — multipart/form-data 构建与解析](#本次更新-2026-06-22--multipartform-data-构建与解析) - [本次更新 (2026-06-22) — 配置与日志的机密脱敏](#本次更新-2026-06-22--配置与日志的机密脱敏) @@ -142,6 +143,12 @@ --- +## 本次更新 (2026-06-22) — Cookie Jar(HTTP 会话携带) + +跨 HTTP 调用携带会话。完整参考:[`docs/source/Zh/doc/new_features/v91_features_doc.rst`](../docs/source/Zh/doc/new_features/v91_features_doc.rst)。 + +- **`CookieJar` / `parse_set_cookie`**(`AC_cookie_header`、`AC_parse_set_cookie`):`http_request` 无状态 —— 没有会话 cookie 在调用间延续,login-then-call 流程无法在无头情况下携带会话。本功能把 `Set-Cookie` 标头解析进 jar、构建 `Cookie` 请求标头,并以 JSON 存/读 jar(`Max-Age<=0`/空值时清除)。纯标准库、确定。 + ## 本次更新 (2026-06-22) — HTTP 内容协商与解压 构建 `Accept` 标头并解码 gzip/deflate。完整参考:[`docs/source/Zh/doc/new_features/v90_features_doc.rst`](../docs/source/Zh/doc/new_features/v90_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 18c18522..63783f46 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-22) — Cookie Jar(HTTP 工作階段攜帶)](#本次更新-2026-06-22--cookie-jarhttp-工作階段攜帶) - [本次更新 (2026-06-22) — HTTP 內容協商與解壓縮](#本次更新-2026-06-22--http-內容協商與解壓縮) - [本次更新 (2026-06-22) — multipart/form-data 建立與解析](#本次更新-2026-06-22--multipartform-data-建立與解析) - [本次更新 (2026-06-22) — 設定與日誌的機密遮蔽](#本次更新-2026-06-22--設定與日誌的機密遮蔽) @@ -142,6 +143,12 @@ --- +## 本次更新 (2026-06-22) — Cookie Jar(HTTP 工作階段攜帶) + +跨 HTTP 呼叫攜帶工作階段。完整參考:[`docs/source/Zh/doc/new_features/v91_features_doc.rst`](../docs/source/Zh/doc/new_features/v91_features_doc.rst)。 + +- **`CookieJar` / `parse_set_cookie`**(`AC_cookie_header`、`AC_parse_set_cookie`):`http_request` 無狀態 —— 沒有工作階段 cookie 在呼叫間延續,login-then-call 流程無法在無頭情況下攜帶工作階段。本功能把 `Set-Cookie` 標頭解析進 jar、建立 `Cookie` 請求標頭,並以 JSON 存/讀 jar(`Max-Age<=0`/空值時清除)。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-22) — HTTP 內容協商與解壓縮 建立 `Accept` 標頭並解碼 gzip/deflate。完整參考:[`docs/source/Zh/doc/new_features/v90_features_doc.rst`](../docs/source/Zh/doc/new_features/v90_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v91_features_doc.rst b/docs/source/Eng/doc/new_features/v91_features_doc.rst new file mode 100644 index 00000000..760ece04 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v91_features_doc.rst @@ -0,0 +1,41 @@ +Cookie Jar (HTTP Session Carry) +=============================== + +``http_request`` is stateless — no session cookies persist across calls, so a +login-then-call REST flow could not carry a session headlessly. This parses +``Set-Cookie`` response headers into a jar and builds the ``Cookie`` request +header; the jar is JSON-serialisable so a session can be saved and reloaded. + +Pure standard library (``json``); imports no ``PySide6``. The jar is a simple +in-memory name-value store (cookies cleared on ``Max-Age<=0`` / empty value), +so behaviour is fully deterministic in CI. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import CookieJar, parse_set_cookie + + jar = CookieJar() + jar.update(login_response_set_cookie_headers) # str or list of Set-Cookie + cookie = jar.cookie_header() # "sid=abc; theme=dark" + # send `cookie` as the Cookie header on subsequent requests + + jar.save("session.json") + jar = CookieJar.load("session.json") + +``parse_set_cookie`` parses one ``Set-Cookie`` value into ``{name, value, +attributes}``. ``CookieJar.update`` applies one or many ``Set-Cookie`` headers +(removing a cookie on an empty value or ``Max-Age<=0``); ``set`` assigns +directly; ``cookie_header`` builds the request header; ``to_dict`` / ``from_dict`` +and ``save`` / ``load`` persist the jar as JSON. (Domain/path matching is +simplified — this is a session-carry jar, not a full RFC 6265 policy engine.) + +Executor commands +----------------- + +``AC_cookie_header`` builds ``{cookie_header, cookies}`` from one or many +``set_cookies``; ``AC_parse_set_cookie`` returns ``{cookie}`` for one header. +Both are exposed as MCP tools (``ac_cookie_header`` / ``ac_parse_set_cookie``) +and as Script Builder commands under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 5135094a..1b860133 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -113,6 +113,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v88_features_doc doc/new_features/v89_features_doc doc/new_features/v90_features_doc + doc/new_features/v91_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/v91_features_doc.rst b/docs/source/Zh/doc/new_features/v91_features_doc.rst new file mode 100644 index 00000000..4ddb5807 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v91_features_doc.rst @@ -0,0 +1,36 @@ +Cookie Jar(HTTP 工作階段攜帶) +============================= + +``http_request`` 無狀態 —— 沒有任何工作階段 cookie 會在呼叫間延續,因此 login-then-call 的 REST 流程 +無法在無頭情況下攜帶工作階段。本功能把 ``Set-Cookie`` 回應標頭解析進一個 jar 並建立 ``Cookie`` 請求標頭; +jar 可序列化為 JSON,因此工作階段可存檔與重新載入。 + +純標準函式庫(``json``);不匯入 ``PySide6``。jar 為簡單的記憶體內名稱-值儲存(``Max-Age<=0`` / 空值時 +清除 cookie),因此行為在 CI 中完全具決定性。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import CookieJar, parse_set_cookie + + jar = CookieJar() + jar.update(login_response_set_cookie_headers) # str 或 Set-Cookie 清單 + cookie = jar.cookie_header() # "sid=abc; theme=dark" + # 後續請求把 `cookie` 當作 Cookie 標頭送出 + + jar.save("session.json") + jar = CookieJar.load("session.json") + +``parse_set_cookie`` 把單一 ``Set-Cookie`` 值解析成 ``{name, value, attributes}``。``CookieJar.update`` +套用一或多個 ``Set-Cookie`` 標頭(空值或 ``Max-Age<=0`` 時移除 cookie);``set`` 直接指定;``cookie_header`` +建立請求標頭;``to_dict`` / ``from_dict`` 與 ``save`` / ``load`` 以 JSON 持久化 jar。(網域/路徑比對為簡化版 +—— 這是工作階段攜帶用的 jar,而非完整 RFC 6265 政策引擎。) + +執行器命令 +---------- + +``AC_cookie_header`` 從一或多個 ``set_cookies`` 建立 ``{cookie_header, cookies}``;``AC_parse_set_cookie`` +對單一標頭回傳 ``{cookie}``。兩者皆以 MCP 工具(``ac_cookie_header`` / ``ac_parse_set_cookie``)以及 +Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 11466a96..a1c7831e 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -113,6 +113,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v88_features_doc doc/new_features/v89_features_doc doc/new_features/v90_features_doc + doc/new_features/v91_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 c020a5fc..9b853385 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -429,6 +429,8 @@ build_accept, build_accept_encoding, decode_body, negotiated_call, parse_quality_values, ) +# RFC 6265 cookie jar (carry a session across HTTP calls) +from je_auto_control.utils.cookie_jar import CookieJar, parse_set_cookie # W3C Trace Context propagation (traceparent / tracestate) from je_auto_control.utils.trace_context import ( SpanContext, TraceContextError, child_context, extract_context, @@ -955,6 +957,7 @@ def start_autocontrol_gui(*args, **kwargs): "MultipartFile", "build_multipart", "new_boundary", "parse_multipart", "build_accept", "build_accept_encoding", "decode_body", "negotiated_call", "parse_quality_values", + "CookieJar", "parse_set_cookie", "SpanContext", "TraceContextError", "child_context", "extract_context", "format_traceparent", "format_tracestate", "inject_context", "new_root_context", "new_span_id", "new_trace_id", "parse_traceparent", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 9c2b411c..873a8b84 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1784,6 +1784,22 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Parse an Accept/Accept-Encoding header by q-value.", )) + specs.append(CommandSpec( + "AC_cookie_header", "Data", "Cookie Jar: Build Cookie Header", + fields=( + FieldSpec("set_cookies", FieldType.STRING, + placeholder='sid=abc; Path=/ (or ["a=1", "b=2"])'), + ), + description="Build a Cookie request header from Set-Cookie value(s).", + )) + specs.append(CommandSpec( + "AC_parse_set_cookie", "Data", "Cookie Jar: Parse Set-Cookie", + fields=( + FieldSpec("header", FieldType.STRING, + placeholder="sid=abc; Path=/; Max-Age=3600"), + ), + description="Parse one Set-Cookie header into name/value/attributes.", + )) specs.append(CommandSpec( "AC_resolve_config", "Data", "Layered Config: Resolve", fields=( diff --git a/je_auto_control/utils/cookie_jar/__init__.py b/je_auto_control/utils/cookie_jar/__init__.py new file mode 100644 index 00000000..d8cf7c81 --- /dev/null +++ b/je_auto_control/utils/cookie_jar/__init__.py @@ -0,0 +1,6 @@ +"""RFC 6265 cookie jar for AutoControl HTTP sessions.""" +from je_auto_control.utils.cookie_jar.cookie_jar import ( + CookieJar, parse_set_cookie, +) + +__all__ = ["CookieJar", "parse_set_cookie"] diff --git a/je_auto_control/utils/cookie_jar/cookie_jar.py b/je_auto_control/utils/cookie_jar/cookie_jar.py new file mode 100644 index 00000000..cc3910d0 --- /dev/null +++ b/je_auto_control/utils/cookie_jar/cookie_jar.py @@ -0,0 +1,97 @@ +"""A small RFC 6265 cookie jar for carrying a session across HTTP calls. + +``http_request`` is stateless — no session cookies persist across calls, so a +login-then-call REST flow could not carry a session headlessly. This parses +``Set-Cookie`` response headers into a jar and builds the ``Cookie`` request +header; the jar is JSON-serialisable so a session can be saved and reloaded. + +Pure standard library (``json``); imports no ``PySide6``. The jar is a simple +in-memory name-value store (cookies cleared on ``max-age<=0`` / empty value), so +behaviour is fully deterministic in CI. +""" +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +SetCookie = Union[str, List[str]] + + +def parse_set_cookie(header: str) -> Optional[Dict[str, Any]]: + """Parse one ``Set-Cookie`` value into ``{name, value, attributes}``.""" + segments = [segment.strip() for segment in (header or "").split(";")] + if not segments or "=" not in segments[0]: + return None + name, _, value = segments[0].partition("=") + attributes: Dict[str, str] = {} + for segment in segments[1:]: + key, sep, attr_value = segment.partition("=") + attributes[key.strip().lower()] = attr_value.strip() if sep else "" + return {"name": name.strip(), "value": value.strip(), + "attributes": attributes} + + +def _is_expired(attributes: Dict[str, str]) -> bool: + max_age = attributes.get("max-age") + if max_age is not None: + try: + return int(max_age) <= 0 + except ValueError: + return False + return False + + +class CookieJar: + """An in-memory name-value cookie jar (RFC 6265, simplified).""" + + def __init__(self, cookies: Optional[Dict[str, str]] = None) -> None: + self._cookies: Dict[str, str] = dict(cookies or {}) + + def update(self, set_cookie: SetCookie) -> "CookieJar": + """Apply one or more ``Set-Cookie`` headers to the jar.""" + headers = [set_cookie] if isinstance(set_cookie, str) else set_cookie + for header in headers: + self._apply_one(header) + return self + + def _apply_one(self, header: str) -> None: + parsed = parse_set_cookie(header) + if parsed is None: + return + if not parsed["value"] or _is_expired(parsed["attributes"]): + self._cookies.pop(parsed["name"], None) + else: + self._cookies[parsed["name"]] = parsed["value"] + + def set(self, name: str, value: str) -> "CookieJar": + """Set a cookie value directly.""" + self._cookies[str(name)] = str(value) + return self + + def cookie_header(self) -> str: + """Build the ``Cookie`` request header value from stored cookies.""" + return "; ".join(f"{name}={value}" + for name, value in self._cookies.items()) + + def to_dict(self) -> Dict[str, str]: + """Return the stored cookies as a plain dict.""" + return dict(self._cookies) + + def __len__(self) -> int: + return len(self._cookies) + + @classmethod + def from_dict(cls, data: Dict[str, str]) -> "CookieJar": + """Build a jar from a plain ``{name: value}`` dict.""" + return cls(data) + + def save(self, path: str) -> str: + """Persist the jar to ``path`` as JSON; return the path.""" + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(self._cookies, indent=2), encoding="utf-8") + return str(out) + + @classmethod + def load(cls, path: str) -> "CookieJar": + """Load a jar from a JSON file.""" + return cls(json.loads(Path(path).read_text(encoding="utf-8"))) diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 12945b89..e6cb35f4 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3030,6 +3030,22 @@ def _redact_secret_text(text: str, mask: str = "***") -> Dict[str, Any]: return {"text": redact_secret_text(text, mask=mask)} +def _cookie_header(set_cookies: Any) -> Dict[str, Any]: + """Adapter: build a Cookie header from one/many Set-Cookie strings.""" + import json + from je_auto_control.utils.cookie_jar import CookieJar + if isinstance(set_cookies, str) and set_cookies.strip().startswith("["): + set_cookies = json.loads(set_cookies) + jar = CookieJar().update(set_cookies) + return {"cookie_header": jar.cookie_header(), "cookies": jar.to_dict()} + + +def _parse_set_cookie(header: str) -> Dict[str, Any]: + """Adapter: parse one Set-Cookie header into its components.""" + from je_auto_control.utils.cookie_jar import parse_set_cookie + return {"cookie": parse_set_cookie(header)} + + def _decode_body(headers: Any, body_base64: str) -> Dict[str, Any]: """Adapter: decode a Content-Encoding (gzip/deflate) base64 body.""" import base64 @@ -4354,6 +4370,8 @@ def __init__(self): "AC_parse_multipart": _parse_multipart, "AC_decode_body": _decode_body, "AC_parse_quality_values": _parse_quality_values, + "AC_cookie_header": _cookie_header, + "AC_parse_set_cookie": _parse_set_cookie, "AC_profile_rows": _profile_rows, "AC_infer_schema": _infer_schema, "AC_parse_problem": _parse_problem, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index aebf604a..3bb6c98c 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,30 @@ def rate_limit_tools() -> List[MCPTool]: ] +def cookie_jar_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_cookie_header", + description=("Build a Cookie request header from one or many " + "'set_cookies' (Set-Cookie strings or a JSON list). " + "Returns {cookie_header, cookies}."), + input_schema=schema( + {"set_cookies": {"type": ["string", "array"]}}, + ["set_cookies"]), + handler=h.cookie_header, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_parse_set_cookie", + description=("Parse one Set-Cookie 'header' into {cookie: {name, " + "value, attributes}} (or null)."), + input_schema=schema({"header": {"type": "string"}}, ["header"]), + handler=h.parse_set_cookie, + annotations=READ_ONLY, + ), + ] + + def http_content_tools() -> List[MCPTool]: return [ MCPTool( @@ -5272,7 +5296,7 @@ def media_assert_tools() -> List[MCPTool]: data_profile_tools, http_problem_tools, dotenv_tools, sse_client_tools, layered_config_tools, data_drift_tools, dataset_diff_tools, referential_tools, link_header_tools, multipart_tools, - http_content_tools, + http_content_tools, cookie_jar_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 e37f8b30..57611d42 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1777,6 +1777,16 @@ def parse_quality_values(header): return _parse_quality_values(header) +def cookie_header(set_cookies): + from je_auto_control.utils.executor.action_executor import _cookie_header + return _cookie_header(set_cookies) + + +def parse_set_cookie(header): + from je_auto_control.utils.executor.action_executor import _parse_set_cookie + return _parse_set_cookie(header) + + def redact_config(obj, mask="***"): from je_auto_control.utils.executor.action_executor import _redact_config return _redact_config(obj, mask) diff --git a/test/unit_test/headless/test_cookie_jar_batch.py b/test/unit_test/headless/test_cookie_jar_batch.py new file mode 100644 index 00000000..1f136064 --- /dev/null +++ b/test/unit_test/headless/test_cookie_jar_batch.py @@ -0,0 +1,77 @@ +"""Headless tests for the RFC 6265 cookie jar. Pure stdlib, no Qt.""" +import json + +import je_auto_control as ac +from je_auto_control.utils.cookie_jar import CookieJar, parse_set_cookie + + +def test_parse_set_cookie(): + parsed = parse_set_cookie("sid=abc123; Path=/; Max-Age=3600; HttpOnly") + assert parsed["name"] == "sid" and parsed["value"] == "abc123" + assert parsed["attributes"]["path"] == "/" + assert parsed["attributes"]["max-age"] == "3600" + assert parsed["attributes"]["httponly"] == "" + assert parse_set_cookie("novalue") is None + + +def test_update_and_cookie_header(): + jar = CookieJar().update(["sid=abc; Path=/", "theme=dark"]) + assert len(jar) == 2 + header = jar.cookie_header() + assert "sid=abc" in header and "theme=dark" in header + assert "; " in header + + +def test_single_string_update(): + jar = CookieJar().update("token=xyz; Secure") + assert jar.to_dict() == {"token": "xyz"} + + +def test_expired_or_empty_removes_cookie(): + jar = CookieJar({"sid": "abc"}) + jar.update("sid=; Max-Age=0") + assert "sid" not in jar.to_dict() + jar.set("a", "1").update("a=; Max-Age=-1") + assert "a" not in jar.to_dict() + + +def test_set_and_dict_round_trip(): + jar = CookieJar().set("k", "v") + restored = CookieJar.from_dict(jar.to_dict()) + assert restored.to_dict() == {"k": "v"} + + +def test_save_and_load(tmp_path): + path = str(tmp_path / "jar.json") + CookieJar({"sid": "abc"}).save(path) + assert CookieJar.load(path).cookie_header() == "sid=abc" + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_cookie_header", + {"set_cookies": json.dumps(["sid=abc", "t=1"])}]]) + out = next(v for v in rec.values() if isinstance(v, dict)) + assert out["cookies"] == {"sid": "abc", "t": "1"} + rec2 = ac.execute_action([[ + "AC_parse_set_cookie", {"header": "sid=abc; Path=/"}]]) + cookie = next(v for v in rec2.values() if isinstance(v, dict))["cookie"] + assert cookie["name"] == "sid" + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_cookie_header", "AC_parse_set_cookie"} <= set(known) + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_cookie_header", "ac_parse_set_cookie"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_cookie_header", "AC_parse_set_cookie"} <= specs + + +def test_facade_exports(): + for attr in ("CookieJar", "parse_set_cookie"): + assert hasattr(ac, attr) and attr in ac.__all__