diff --git a/README.md b/README.md index 9d9097f3..5f06031f 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-19) — CI Annotations & Clipboard History](#whats-new-2026-06-19--ci-annotations--clipboard-history) - [What's new (2026-06-19) — Resilience Primitives](#whats-new-2026-06-19--resilience-primitives) - [What's new (2026-06-19) — Timed Input Macros](#whats-new-2026-06-19--timed-input-macros) - [What's new (2026-06-19) — Semantic Screen State](#whats-new-2026-06-19--semantic-screen-state) @@ -78,6 +79,13 @@ --- +## What's new (2026-06-19) — CI Annotations & Clipboard History + +Two pure-stdlib utilities. Full reference: [`docs/source/Eng/doc/new_features/v26_features_doc.rst`](docs/source/Eng/doc/new_features/v26_features_doc.rst). + +- **CI annotations** — `emit_annotations(results)` (`AC_ci_annotations`, `ac_ci_annotations`): turn result dicts into GitHub Actions workflow commands (`::error file=...,line=...::msg`) so failures show inline in a PR, no reporter action needed. +- **Clipboard history** — `ClipboardHistory` / `default_clipboard_history` (`AC_clip_history_capture`/`list`/`search`/`start`/`stop`, `ac_clip_history_*`): a capped, searchable, newest-first ring buffer of copied text with an optional background poller. + ## What's new (2026-06-19) — Resilience Primitives Reusable retry + circuit-breaker primitives. Full reference: [`docs/source/Eng/doc/new_features/v25_features_doc.rst`](docs/source/Eng/doc/new_features/v25_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 51224e77..299e8f8c 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-19) — CI 注解与剪贴板历史](#本次更新-2026-06-19--ci-注解与剪贴板历史) - [本次更新 (2026-06-19) — 韧性原语](#本次更新-2026-06-19--韧性原语) - [本次更新 (2026-06-19) — 计时输入宏](#本次更新-2026-06-19--计时输入宏) - [本次更新 (2026-06-19) — 语义屏幕状态](#本次更新-2026-06-19--语义屏幕状态) @@ -77,6 +78,13 @@ --- +## 本次更新 (2026-06-19) — CI 注解与剪贴板历史 + +两项纯标准库工具。完整参考:[`docs/source/Zh/doc/new_features/v26_features_doc.rst`](../docs/source/Zh/doc/new_features/v26_features_doc.rst)。 + +- **CI 注解** — `emit_annotations(results)`(`AC_ci_annotations`、`ac_ci_annotations`):把结果 dict 转成 GitHub Actions 工作流命令(`::error file=...,line=...::msg`),让失败在 PR 行内显示,免 reporter action。 +- **剪贴板历史** — `ClipboardHistory` / `default_clipboard_history`(`AC_clip_history_capture`/`list`/`search`/`start`/`stop`、`ac_clip_history_*`):有上限、可搜索、最新在前的复制文本环形缓冲,含可选后台轮询器。 + ## 本次更新 (2026-06-19) — 韧性原语 可重用的 retry 与断路器原语。完整参考:[`docs/source/Zh/doc/new_features/v25_features_doc.rst`](../docs/source/Zh/doc/new_features/v25_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index e15858c2..cf7d9a5d 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-19) — CI 註解與剪貼簿歷史](#本次更新-2026-06-19--ci-註解與剪貼簿歷史) - [本次更新 (2026-06-19) — 韌性原語](#本次更新-2026-06-19--韌性原語) - [本次更新 (2026-06-19) — 計時輸入巨集](#本次更新-2026-06-19--計時輸入巨集) - [本次更新 (2026-06-19) — 語意螢幕狀態](#本次更新-2026-06-19--語意螢幕狀態) @@ -77,6 +78,13 @@ --- +## 本次更新 (2026-06-19) — CI 註解與剪貼簿歷史 + +兩項純標準庫工具。完整參考:[`docs/source/Zh/doc/new_features/v26_features_doc.rst`](../docs/source/Zh/doc/new_features/v26_features_doc.rst)。 + +- **CI 註解** — `emit_annotations(results)`(`AC_ci_annotations`、`ac_ci_annotations`):把結果 dict 轉成 GitHub Actions 工作流程命令(`::error file=...,line=...::msg`),讓失敗在 PR 行內顯示,免 reporter action。 +- **剪貼簿歷史** — `ClipboardHistory` / `default_clipboard_history`(`AC_clip_history_capture`/`list`/`search`/`start`/`stop`、`ac_clip_history_*`):有上限、可搜尋、最新在前的複製文字環狀緩衝,含可選背景輪詢器。 + ## 本次更新 (2026-06-19) — 韌性原語 可重用的 retry 與斷路器原語。完整參考:[`docs/source/Zh/doc/new_features/v25_features_doc.rst`](../docs/source/Zh/doc/new_features/v25_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v26_features_doc.rst b/docs/source/Eng/doc/new_features/v26_features_doc.rst new file mode 100644 index 00000000..f8326eb7 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v26_features_doc.rst @@ -0,0 +1,49 @@ +================================================== +New Features (2026-06-19) — CI Annotations & Clipboard History +================================================== + +Two pure-standard-library utilities: emit CI annotations from results, and +keep a searchable clipboard history. Full stack. + +.. contents:: + :local: + :depth: 2 + + +CI workflow annotations +====================== + +:: + + from je_auto_control import emit_annotations + + emit_annotations([ + {"level": "error", "message": "step failed", + "file": "flows/login.json", "line": 12, "title": "Login"}, + ]) + # prints: ::error file=flows/login.json,line=12,title=Login::step failed + +Converts result dicts (``{level, message, file?, line?, col?, title?}``) +into GitHub Actions workflow commands so failures surface **inline** in a PR +— no third-party reporter action required. ``level`` is ``error`` / +``warning`` / ``notice``; values are escaped per GitHub's rules. Exposed as +``AC_ci_annotations`` / ``ac_ci_annotations``. + + +Clipboard history +================ + +:: + + from je_auto_control import ClipboardHistory, default_clipboard_history + + default_clipboard_history.start() # poll the clipboard in the background + default_clipboard_history.search("invoice") + default_clipboard_history.get(0) # most recent entry + +A capped, newest-first ring buffer of distinct clipboard text entries with +``add`` / ``snapshot`` / ``get`` / ``search`` / ``clear`` and an optional +background poller (``start`` / ``stop`` / ``capture_once``). Exposed as +``AC_clip_history_capture`` / ``AC_clip_history_list`` / +``AC_clip_history_search`` / ``AC_clip_history_start`` / +``AC_clip_history_stop`` (and ``ac_clip_history_*``). diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 6c6014d7..d1009609 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -48,6 +48,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v23_features_doc doc/new_features/v24_features_doc doc/new_features/v25_features_doc + doc/new_features/v26_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/v26_features_doc.rst b/docs/source/Zh/doc/new_features/v26_features_doc.rst new file mode 100644 index 00000000..fb10e77b --- /dev/null +++ b/docs/source/Zh/doc/new_features/v26_features_doc.rst @@ -0,0 +1,47 @@ +================================================== +新功能 (2026-06-19) — CI 註解與剪貼簿歷史 +================================================== + +兩項純標準庫工具:從結果輸出 CI 註解,以及保存可搜尋的剪貼簿歷史。 +走完整五層。 + +.. contents:: + :local: + :depth: 2 + + +CI 工作流程註解 +=============== + +:: + + from je_auto_control import emit_annotations + + emit_annotations([ + {"level": "error", "message": "step failed", + "file": "flows/login.json", "line": 12, "title": "Login"}, + ]) + # 印出:::error file=flows/login.json,line=12,title=Login::step failed + +把結果 dict(``{level, message, file?, line?, col?, title?}``)轉成 GitHub +Actions 工作流程命令,讓失敗在 PR 中**行內**顯示——不需第三方 reporter +action。``level`` 為 ``error`` / ``warning`` / ``notice``;值會依 GitHub +規則轉義。對應 ``AC_ci_annotations`` / ``ac_ci_annotations``。 + + +剪貼簿歷史 +========== + +:: + + from je_auto_control import ClipboardHistory, default_clipboard_history + + default_clipboard_history.start() # 背景輪詢剪貼簿 + default_clipboard_history.search("invoice") + default_clipboard_history.get(0) # 最近一筆 + +一個有上限、最新在前、去重的剪貼簿文字環狀緩衝,具 ``add`` / ``snapshot`` +/ ``get`` / ``search`` / ``clear`` 及可選的背景輪詢器(``start`` / ``stop`` +/ ``capture_once``)。對應 ``AC_clip_history_capture`` / +``AC_clip_history_list`` / ``AC_clip_history_search`` / +``AC_clip_history_start`` / ``AC_clip_history_stop``(以及 ``ac_clip_history_*``)。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 7c20da12..a291da5b 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -48,6 +48,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v23_features_doc doc/new_features/v24_features_doc doc/new_features/v25_features_doc + doc/new_features/v26_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 953adba4..79f32897 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -180,6 +180,14 @@ from je_auto_control.utils.resilience import ( CircuitBreaker, CircuitOpenError, RetryPolicy, retry_call, ) +# CI workflow annotations (GitHub Actions) +from je_auto_control.utils.ci_annotations import ( + emit_annotations, format_annotation, +) +# Clipboard history (ring buffer + background poller) +from je_auto_control.utils.clipboard_history import ( + ClipboardHistory, default_clipboard_history, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -609,6 +617,8 @@ def start_autocontrol_gui(*args, **kwargs): "snapshot_screen", "replay_timeline", "run_sequence", "CircuitBreaker", "CircuitOpenError", "RetryPolicy", "retry_call", + "emit_annotations", "format_annotation", + "ClipboardHistory", "default_clipboard_history", # 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 9865972d..33838a5d 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -665,6 +665,26 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: _add_screen_state_specs(specs) _add_input_macro_specs(specs) _add_resilience_specs(specs) + _add_devex_specs(specs) + + +def _add_devex_specs(specs: List[CommandSpec]) -> None: + specs.append(CommandSpec( + "AC_ci_annotations", "Tools", "Emit CI Annotations", + description="Emit GitHub Actions annotations from 'annotations' " + "(JSON view).", + )) + specs.append(CommandSpec( + "AC_clip_history_capture", "Misc", "Clipboard History: Capture")) + specs.append(CommandSpec( + "AC_clip_history_list", "Misc", "Clipboard History: List")) + specs.append(CommandSpec( + "AC_clip_history_search", "Misc", "Clipboard History: Search", + fields=(FieldSpec("query", FieldType.STRING),))) + specs.append(CommandSpec( + "AC_clip_history_start", "Misc", "Clipboard History: Start")) + specs.append(CommandSpec( + "AC_clip_history_stop", "Misc", "Clipboard History: Stop")) def _add_resilience_specs(specs: List[CommandSpec]) -> None: diff --git a/je_auto_control/utils/ci_annotations/__init__.py b/je_auto_control/utils/ci_annotations/__init__.py new file mode 100644 index 00000000..ca728aed --- /dev/null +++ b/je_auto_control/utils/ci_annotations/__init__.py @@ -0,0 +1,6 @@ +"""Emit CI workflow annotations (GitHub Actions) from run results.""" +from je_auto_control.utils.ci_annotations.ci_annotations import ( + emit_annotations, format_annotation, +) + +__all__ = ["emit_annotations", "format_annotation"] diff --git a/je_auto_control/utils/ci_annotations/ci_annotations.py b/je_auto_control/utils/ci_annotations/ci_annotations.py new file mode 100644 index 00000000..4e15cde7 --- /dev/null +++ b/je_auto_control/utils/ci_annotations/ci_annotations.py @@ -0,0 +1,56 @@ +"""Emit CI workflow annotations from run results (GitHub Actions format). + +A JUnit file needs a third-party reporter action to surface failures in a +PR. Emitting GitHub Actions *workflow commands* (``::error file=...,line=..., +title=...::message``) prints failures as inline annotations with zero extra +config. This converts a list of result dicts into those lines. + +Pure standard library; imports no ``PySide6``. +""" +import sys +from typing import Any, Dict, List, Optional, TextIO + +_LEVELS = {"error", "warning", "notice"} + + +def _escape(value: str) -> str: + """Escape a workflow-command property value per GitHub's rules.""" + return (str(value).replace("%", "%25").replace("\r", "%0D") + .replace("\n", "%0A").replace(":", "%3A").replace(",", "%2C")) + + +def _escape_message(value: str) -> str: + return str(value).replace("%", "%25").replace("\r", "%0D").replace( + "\n", "%0A") + + +def format_annotation(annotation: Dict[str, Any]) -> str: + """Format one annotation as a GitHub Actions workflow command. + + ``{level, message, file?, line?, col?, title?}``; ``level`` is + ``error`` / ``warning`` / ``notice`` (defaults to ``error``). + """ + level = str(annotation.get("level", "error")).lower() + if level not in _LEVELS: + level = "error" + props = [] + for key, prop in (("file", "file"), ("line", "line"), ("col", "col"), + ("title", "title")): + value = annotation.get(key) + if value not in (None, ""): + props.append(f"{prop}={_escape(value)}") + prefix = f"::{level} " + ",".join(props) if props else f"::{level}" + return f"{prefix}::{_escape_message(annotation.get('message', ''))}" + + +def emit_annotations(annotations: List[Dict[str, Any]], *, + stream: Optional[TextIO] = None) -> List[str]: + """Format each annotation and write the lines to ``stream`` (stdout). + + Returns the formatted lines (also useful for tests / piping). + """ + out = stream if stream is not None else sys.stdout + lines = [format_annotation(item) for item in annotations] + for line in lines: + out.write(line + "\n") + return lines diff --git a/je_auto_control/utils/clipboard_history/__init__.py b/je_auto_control/utils/clipboard_history/__init__.py new file mode 100644 index 00000000..25bd9a3b --- /dev/null +++ b/je_auto_control/utils/clipboard_history/__init__.py @@ -0,0 +1,6 @@ +"""Clipboard history: a ring buffer + background poller over the clipboard.""" +from je_auto_control.utils.clipboard_history.clipboard_history import ( + ClipboardHistory, default_clipboard_history, +) + +__all__ = ["ClipboardHistory", "default_clipboard_history"] diff --git a/je_auto_control/utils/clipboard_history/clipboard_history.py b/je_auto_control/utils/clipboard_history/clipboard_history.py new file mode 100644 index 00000000..161e0fb6 --- /dev/null +++ b/je_auto_control/utils/clipboard_history/clipboard_history.py @@ -0,0 +1,103 @@ +"""Clipboard history — a capped ring buffer with a background poller. + +AutoControl can get/set the *current* clipboard but keeps no history. This +records the last ``capacity`` distinct text entries (newest first) and can +poll the clipboard on a background thread to capture entries as they change +— so a flow can recall or search what was copied earlier. + +Pure standard library; the clipboard backend is imported lazily so the ring +buffer (``add`` / ``snapshot`` / ``search`` / ``get``) is unit-testable +without a real clipboard. Thread-safe. +""" +import threading +from typing import List, Optional + + +class ClipboardHistory: + """A capped, newest-first history of distinct clipboard text entries.""" + + def __init__(self, capacity: int = 50, poll_interval_s: float = 1.0 + ) -> None: + self._capacity = max(1, int(capacity)) + self._poll = max(0.05, float(poll_interval_s)) + self._items: List[str] = [] + self._lock = threading.Lock() + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + + def add(self, text: str) -> bool: + """Record ``text`` (newest first); skip empty or unchanged-top. + + Returns whether it was added. + """ + if not text: + return False + with self._lock: + if self._items and self._items[0] == text: + return False + if text in self._items: + self._items.remove(text) + self._items.insert(0, text) + del self._items[self._capacity:] + return True + + def snapshot(self) -> List[str]: + """Return the history, newest first.""" + with self._lock: + return list(self._items) + + def get(self, index: int = 0) -> Optional[str]: + """Return the entry at ``index`` (0 = most recent) or ``None``.""" + with self._lock: + if 0 <= index < len(self._items): + return self._items[index] + return None + + def search(self, query: str) -> List[str]: + """Return entries containing ``query`` (case-insensitive).""" + needle = str(query).lower() + with self._lock: + return [item for item in self._items if needle in item.lower()] + + def clear(self) -> None: + """Drop all history.""" + with self._lock: + self._items.clear() + + @property + def running(self) -> bool: + """Whether the background poll thread is alive.""" + return self._thread is not None and self._thread.is_alive() + + def capture_once(self) -> bool: + """Read the live clipboard once and record it; return whether added.""" + from je_auto_control.utils.clipboard.clipboard import get_clipboard + try: + return self.add(get_clipboard()) + except (OSError, RuntimeError, ValueError): + return False + + def start(self) -> None: + """Start polling the clipboard on a background thread (idempotent).""" + if self.running: + return + self._stop.clear() + self._thread = threading.Thread( + target=self._loop, name="clipboard-history", daemon=True) + self._thread.start() + + def stop(self, timeout: float = 2.0) -> None: + """Signal the poll thread to stop and join it.""" + self._stop.set() + thread = self._thread + if thread is not None: + thread.join(timeout=float(timeout)) + self._thread = None + + def _loop(self) -> None: + while not self._stop.is_set(): + self.capture_once() + self._stop.wait(self._poll) + + +default_clipboard_history = ClipboardHistory() diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 40414a88..ab058711 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2808,6 +2808,49 @@ def _circuit_call(name: str, actions: List[Any], threshold: int = 5, return {"state": breaker.state, "record": record} +def _ci_annotations(annotations: List[Dict[str, Any]]) -> Dict[str, Any]: + """Adapter: emit GitHub Actions annotations from result dicts.""" + from je_auto_control.utils.ci_annotations import emit_annotations + return {"lines": emit_annotations(annotations)} + + +def _clip_history_capture() -> Dict[str, Any]: + """Adapter: capture the live clipboard into history.""" + from je_auto_control.utils.clipboard_history import ( + default_clipboard_history) + return {"added": default_clipboard_history.capture_once()} + + +def _clip_history_list() -> Dict[str, Any]: + """Adapter: list clipboard history (newest first).""" + from je_auto_control.utils.clipboard_history import ( + default_clipboard_history) + return {"history": default_clipboard_history.snapshot()} + + +def _clip_history_search(query: str) -> Dict[str, Any]: + """Adapter: search clipboard history.""" + from je_auto_control.utils.clipboard_history import ( + default_clipboard_history) + return {"matches": default_clipboard_history.search(query)} + + +def _clip_history_start() -> Dict[str, Any]: + """Adapter: start the background clipboard-history poller.""" + from je_auto_control.utils.clipboard_history import ( + default_clipboard_history) + default_clipboard_history.start() + return {"running": default_clipboard_history.running} + + +def _clip_history_stop() -> Dict[str, Any]: + """Adapter: stop the background clipboard-history poller.""" + from je_auto_control.utils.clipboard_history import ( + default_clipboard_history) + default_clipboard_history.stop() + return {"running": default_clipboard_history.running} + + class Executor: """ Executor @@ -3027,6 +3070,12 @@ def __init__(self): "AC_replay_timeline": _replay_timeline, "AC_input_sequence": _input_sequence, "AC_circuit_call": _circuit_call, + "AC_ci_annotations": _ci_annotations, + "AC_clip_history_capture": _clip_history_capture, + "AC_clip_history_list": _clip_history_list, + "AC_clip_history_search": _clip_history_search, + "AC_clip_history_start": _clip_history_start, + "AC_clip_history_stop": _clip_history_stop, "AC_a11y_record_start": _a11y_record_start, "AC_a11y_record_stop": _a11y_record_stop, "AC_a11y_record_events": _a11y_record_events, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 3503093e..e1f058a6 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2420,6 +2420,64 @@ def resilience_tools() -> List[MCPTool]: ] +def ci_annotation_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_ci_annotations", + description=("Emit GitHub Actions workflow annotations from result " + "dicts ({level, message, file?, line?, title?}) so " + "failures show inline in a PR. Returns the {lines}."), + input_schema=schema({ + "annotations": {"type": "array", + "items": {"type": "object"}}}, + required=["annotations"]), + handler=h.ci_annotations, + annotations=READ_ONLY, + ), + ] + + +def clipboard_history_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_clip_history_capture", + description="Capture the live clipboard text into history.", + input_schema=schema({}), + handler=h.clip_history_capture, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_clip_history_list", + description="List the clipboard history (newest first).", + input_schema=schema({}), + handler=h.clip_history_list, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_clip_history_search", + description="Search clipboard history (case-insensitive).", + input_schema=schema({"query": {"type": "string"}}, + required=["query"]), + handler=h.clip_history_search, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_clip_history_start", + description="Start the background clipboard-history poller.", + input_schema=schema({}), + handler=h.clip_history_start, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_clip_history_stop", + description="Stop the background clipboard-history poller.", + input_schema=schema({}), + handler=h.clip_history_stop, + annotations=SIDE_EFFECT_ONLY, + ), + ] + + def unattended_tools() -> List[MCPTool]: return [ MCPTool( @@ -3476,6 +3534,7 @@ def media_assert_tools() -> List[MCPTool]: sbom_tools, sharding_tools, data_quality_tools, i18n_tools, checkpoint_tools, set_of_marks_tools, screen_state_tools, input_macro_tools, resilience_tools, + ci_annotation_tools, clipboard_history_tools, screen_record_tools, process_and_shell_tools, remote_desktop_tools, gamepad_tools, usb_passthrough_tools, assertion_tools, data_source_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 8d603e51..1797d945 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1179,6 +1179,38 @@ def circuit_call(name, actions, threshold=5, reset_s=30.0): reset_s=float(reset_s)) +def ci_annotations(annotations): + from je_auto_control.utils.ci_annotations import emit_annotations + return {"lines": emit_annotations(annotations)} + + +def clip_history_capture(): + from je_auto_control.utils.clipboard_history import default_clipboard_history + return {"added": default_clipboard_history.capture_once()} + + +def clip_history_list(): + from je_auto_control.utils.clipboard_history import default_clipboard_history + return {"history": default_clipboard_history.snapshot()} + + +def clip_history_search(query): + from je_auto_control.utils.clipboard_history import default_clipboard_history + return {"matches": default_clipboard_history.search(query)} + + +def clip_history_start(): + from je_auto_control.utils.clipboard_history import default_clipboard_history + default_clipboard_history.start() + return {"running": default_clipboard_history.running} + + +def clip_history_stop(): + from je_auto_control.utils.clipboard_history import default_clipboard_history + default_clipboard_history.stop() + return {"running": default_clipboard_history.running} + + def vlm_locate(description: str, screen_region: Optional[List[int]] = None, model: Optional[str] = None) -> Optional[List[int]]: diff --git a/test/unit_test/headless/test_devex_batch.py b/test/unit_test/headless/test_devex_batch.py new file mode 100644 index 00000000..9868fcb6 --- /dev/null +++ b/test/unit_test/headless/test_devex_batch.py @@ -0,0 +1,96 @@ +"""Headless tests for the devex batch: CI annotations + clipboard history. +Pure stdlib; no Qt imports, no real clipboard required.""" +import io + +import je_auto_control as ac +from je_auto_control.utils.ci_annotations import ( + emit_annotations, format_annotation) +from je_auto_control.utils.clipboard_history import ClipboardHistory + + +# --- CI annotations ------------------------------------------------------- + +def test_format_annotation_github_command(): + line = format_annotation({"level": "error", "message": "boom", + "file": "a.py", "line": 12, "title": "Fail"}) + assert line == "::error file=a.py,line=12,title=Fail::boom" + + +def test_format_annotation_escapes_and_defaults_level(): + line = format_annotation({"message": "a, b\nc", "file": "x,y.py"}) + assert line.startswith("::error file=x%2Cy.py::") + assert "%0A" in line and line.count("::") >= 2 # newline escaped + + +def test_emit_annotations_writes_and_returns(): + buf = io.StringIO() + lines = emit_annotations( + [{"level": "warning", "message": "w"}, + {"level": "notice", "message": "n"}], stream=buf) + assert lines == ["::warning::w", "::notice::n"] + assert buf.getvalue() == "::warning::w\n::notice::n\n" + + +# --- clipboard history ---------------------------------------------------- + +def test_history_dedup_move_to_front_and_cap(): + hist = ClipboardHistory(capacity=3) + assert hist.add("a") is True + assert hist.add("a") is False # unchanged top -> skipped + hist.add("b") + hist.add("c") + hist.add("a") # re-add moves to front + assert hist.snapshot() == ["a", "c", "b"] + hist.add("d") # cap=3 evicts oldest ("b") + assert hist.snapshot() == ["d", "a", "c"] + assert hist.add("") is False + + +def test_history_get_search_clear(): + hist = ClipboardHistory() + for text in ("alpha", "beta", "alphabet"): + hist.add(text) + assert hist.get(0) == "alphabet" + assert hist.get(99) is None + assert set(hist.search("alpha")) == {"alpha", "alphabet"} + hist.clear() + assert hist.snapshot() == [] + + +def test_capture_once_uses_clipboard(monkeypatch): + import je_auto_control.utils.clipboard.clipboard as clip + monkeypatch.setattr(clip, "get_clipboard", lambda: "copied") + hist = ClipboardHistory() + assert hist.capture_once() is True + assert hist.snapshot() == ["copied"] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_wiring(): + rec = ac.execute_action([["AC_ci_annotations", { + "annotations": [{"level": "error", "message": "x"}]}]]) + assert any("::error::x" in str(v) for v in rec.values()) + known = ac.executor.known_commands() + assert {"AC_ci_annotations", "AC_clip_history_capture", + "AC_clip_history_list", "AC_clip_history_search", + "AC_clip_history_start", "AC_clip_history_stop"} <= known + + +def test_mcp_and_builder_wiring(): + 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_ci_annotations", "ac_clip_history_capture", + "ac_clip_history_list", "ac_clip_history_search"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_ci_annotations", "AC_clip_history_capture", + "AC_clip_history_search"} <= cmds + + +def test_facade_exports(): + for attr in ("emit_annotations", "format_annotation", "ClipboardHistory", + "default_clipboard_history"): + assert hasattr(ac, attr) + assert attr in ac.__all__