diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 039af8b5..d0905b11 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -77,7 +77,7 @@ jobs: autocontrol:ci -c " pip install --no-cache-dir -r dev_requirements.txt && xvfb-run -a -s '-screen 0 1280x800x24' \ - python -m pytest test/unit_test/headless -q --tb=short + python -m pytest -q --tb=short " - name: Smoke test the entrypoint (rest mode) diff --git a/.github/workflows/platform-smoke.yml b/.github/workflows/platform-smoke.yml new file mode 100644 index 00000000..99097a42 --- /dev/null +++ b/.github/workflows/platform-smoke.yml @@ -0,0 +1,51 @@ +name: Platform smoke + +on: + push: + branches: ["main", "dev"] + pull_request: + branches: ["main", "dev"] + +permissions: + contents: read + +jobs: + stable-api: + strategy: + fail-fast: false + matrix: + os: [windows-2022, ubuntu-22.04, macos-14] + python-version: ["3.10", "3.14"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install -e . + # The X11 backend connects to a display at import time, so Linux + # runs need a virtual one. + - name: Install a virtual display (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y xvfb + - name: Import stable API and generate platform-neutral code + shell: bash + run: >- + ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} + python -c "import je_auto_control.api as ac; + compile(ac.generate_code([['AC_screen_size']], style='actions'), + '', 'exec')" + - name: Create headless diagnostic bundle + shell: bash + run: >- + ${{ runner.os == 'Linux' && 'xvfb-run -a' || '' }} + python -c "from je_auto_control.api import + FailureBundleOptions, create_failure_bundle; + create_failure_bundle('platform-smoke.zip', + options=FailureBundleOptions(screenshot=False))" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: platform-smoke-${{ matrix.os }}-${{ matrix.python-version }} + path: platform-smoke.zip + if-no-files-found: warn diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 16b284d8..20e57818 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -16,6 +16,15 @@ permissions: contents: read jobs: + dependency-review: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/dependency-review-action@v4 + lint: runs-on: ubuntu-latest steps: @@ -81,7 +90,29 @@ jobs: # for any sub-package the snapshot doesn't include # (admin, usb, remote_desktop, vision, …). pip install -e . - pip install ruff==0.15.14 bandit==1.9.4 pytest==9.0.3 pytest-timeout==2.4.0 pytest-rerunfailures==15.1 PySide6==6.11.1 + pip install ruff==0.15.14 bandit==1.9.4 pytest==9.0.3 pytest-timeout==2.4.0 pytest-rerunfailures==15.1 pytest-cov==7.0.0 PySide6==6.11.1 + # Paths come from `testpaths` in pyproject.toml. Do NOT pass an explicit + # path here: an argument overrides testpaths, which previously meant the + # flow_control tests were configured to run but silently never did. - name: Run headless pytest suite - run: pytest test/unit_test/headless/ -v --tb=short --timeout=120 + run: >- + pytest -v --tb=short --timeout=120 + --cov=je_auto_control --cov-report=term-missing + --cov-report=xml --cov-fail-under=35 + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + + typing-stable-api: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . mypy + - run: mypy je_auto_control/api je_auto_control/utils/failure_bundle diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3c4edead --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade build twine + - name: Verify tag matches package version + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + python - <<'PY' + import os, tomllib + with open("pyproject.toml", "rb") as handle: + version = tomllib.load(handle)["project"]["version"] + if os.environ["RELEASE_TAG"] != f"v{version}": + raise SystemExit(f"tag {os.environ['RELEASE_TAG']} != v{version}") + PY + - run: python -m build + - run: python -m twine check dist/* + - name: Smoke-test the built wheel + run: | + python -m venv /tmp/wheel-test + /tmp/wheel-test/bin/pip install dist/*.whl + /tmp/wheel-test/bin/python -c "import je_auto_control.api" + - uses: actions/attest-build-provenance@v2 + with: + subject-path: "dist/*" + - uses: actions/upload-artifact@v4 + with: + name: python-distributions + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/je-auto-control + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python-distributions + path: dist/ + - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index 60d36c02..415a57eb 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -118,7 +118,8 @@ jobs: publish: needs: test - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + # Publishing moved to release.yml: immutable v* tags + Trusted Publishing. + if: ${{ false }} runs-on: ubuntu-latest permissions: contents: write diff --git a/.gitignore b/.gitignore index 66c4f152..ab74cb98 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,8 @@ dmypy.json /.claude/ /.claude /.idea + +# Local test/smoke artifacts +.test-tmp/ +bundle-smoke.zip +platform-smoke.zip diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7575e2c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +This file records user-visible compatibility changes. Detailed development +notes remain in `WHATS_NEW.md`. + +The format follows Keep a Changelog. Until 1.0, breaking changes are permitted +only when documented here with a migration path. + +## Unreleased + +### Added + +- Stable, headless `je_auto_control.api` façade. +- Portable `autocontrol.failure-bundle/v1` diagnostic archives and CLI command. +- Public API lifecycle, capability matrix, security policy, coverage and type + checking configuration. + +### Changed + +- Releases are prepared from version tags and use PyPI Trusted Publishing. + +### Deprecated + +- New integrations should avoid the eager, historical top-level import surface + and import stable entry points from `je_auto_control.api`. diff --git a/CLAUDE.md b/CLAUDE.md index df15f5ad..d06de150 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,7 @@ No feature is complete unless it can be driven entirely without the GUI **and** - **Re-export from the package facade**: add the public functions / classes to `je_auto_control/__init__.py` and its `__all__` so `import je_auto_control as ac; ac.(...)` works out of the box. - **Executor command coverage**: wire an `AC_*` command into `utils/executor/action_executor.py` so the feature is usable from JSON action files, the socket server, the scheduler, and the visual script builder — all without Python glue. - **GUI tab or control is a thin wrapper**: the Qt widget must only translate user input into calls on the headless core. It must not contain business logic that would be unreachable headlessly. +- **Tab commands live in the Actions menu, not in-tab buttons**: the main window is menu-driven. A tab keeps only its inputs, tables, and result/status views; its commands surface through the window-level **Actions** menu. Core tabs declare `(label_key, handler)` pairs at registration in `gui/main_widget.py`; feature tabs expose a `menu_actions()` method returning the same shape. Script Builder and Remote Desktop are the only exempt tabs (interactive panel layouts). `test/unit_test/headless/test_actions_menu_gui.py` guards this contract — a new tab without registry actions or a `menu_actions()` hook fails CI. - **The top-level package stays Qt-free**: `import je_auto_control` MUST NOT import `PySide6`. The GUI entry point is loaded lazily inside `start_autocontrol_gui()`. Verify with: ```python diff --git a/README.md b/README.md index 18682fe7..c83b326b 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,10 @@ All per-release notes have moved to **[WHATS_NEW.md](WHATS_NEW.md)**. - **WebRTC Packet Inspector** — process-global rolling window of `StatsSnapshot` samples (default 600 / ~10 min @ 1Hz) fed by the existing WebRTC stats pollers. Per-metric `last/min/max/avg/p95` for RTT, FPS, bitrate, packet loss, jitter - **USB Device Enumeration** — read-only cross-platform device listing. Tries pyusb (libusb) first; falls back to platform-specific (Windows `Get-PnpDevice`, macOS `system_profiler`, Linux `/sys/bus/usb/devices`). Phase 2 passthrough builds on this (see below) - **System Diagnostics** — single-command "is everything OK?" probe across platform, optional deps, executor command count, audit chain, screenshot, mouse, disk space, REST registry. CLI exits 0 if all green / 1 otherwise; REST `/diagnose`; severity-tagged GUI tab +- **Stable API & Failure Bundles** — versioned, lazy `je_auto_control.api` façade for new integrations (`execute_action`, `generate_code`, `run_diagnostics`, failure bundles) with a documented [lifecycle policy](docs/API_LIFECYCLE.md). Portable `autocontrol.failure-bundle/v1` diagnostic ZIPs: manifest + redacted context/events/log tail, optional screenshot and diagnostics, best-effort collectors, atomic write. CLI `je_auto_control failure-bundle out.zip`; `codegen --failure-bundle` wraps generated pytest in automatic failure diagnostics - **USB Hotplug Events** — polling-based hotplug watcher (`UsbHotplugWatcher`) with bounded ring buffer + sequence-numbered events; `GET /usb/events?since=N` lets late subscribers catch up. GUI auto-refresh toggle on the USB tab. - **OpenAPI 3.1 + Swagger UI** — `GET /openapi.json` (auth-gated, generated from the live route table) + `GET /docs` (browser Swagger UI with bearer token bar). Drift test in CI catches new routes added without metadata. -- **Configuration Bundle** — single-file JSON export/import of user config (admin hosts, address book, trusted viewers, known hosts, host service, IDs). Atomic write with `.bak.` backups; CLI `python -m je_auto_control.utils.config_bundle export|import`; `POST /config/{export,import}`; GUI buttons on the REST API tab. +- **Configuration Bundle** — single-file JSON export/import of user config (admin hosts, address book, trusted viewers, known hosts, host service, IDs). Atomic write with `.bak.` backups; CLI `python -m je_auto_control.utils.config_bundle export|import`; `POST /config/{export,import}`; export/import commands on the REST API tab's Actions menu. - **USB Passthrough (opt-in)** — let a remote viewer use a USB device physically attached to the host, over a WebRTC `usb` DataChannel. Wire-level protocol (11 opcodes incl. `RESUME`, CREDIT-based flow control, 16 KiB payload cap with EOF fragmentation for oversize transfers). All eight original open questions resolved: reliable-ordered channel, LIST-over-channel (ACL-filtered), per-claim credits, Linux kernel-driver detach/reattach, and ACL **HMAC-SHA256 integrity** (fail-closed on tamper; pluggable key — Windows DPAPI or passphrase vault). **Backends:** `LibusbBackend` (production), `WinusbBackend` (ctypes) and `IokitBackend` (native IOKit enumeration + libusb transfers) — Windows/macOS *hardware-unverified*; `default_passthrough_backend()` picks per-OS. Viewer-side blocking client (`control/bulk/interrupt_transfer`, `list_devices`, `resume`); in-process `UsbLoopback` so one machine can share + use a device through the full stack. **Wired into WebRTC** host/viewer (`viewer.usb_client()`) plus claim **resume tokens** that survive a reconnect. Persistent ACL (default deny, mode 0600) with host-side prompt dialog, abuse **rate-limit / lockout**, and tamper-evident audit integration. Five driving surfaces: AnyDesk-style **GUI panel** (share + ACL allow/block + local/remote use), `AC_usb_*` executor commands (JSON / socket / scheduler), **REST** `/usb/...`, first-class **MCP** `ac_usb_*` tools, and the Python API. Default off — opt-in via `enable_usb_passthrough(True)` or `JE_AUTOCONTROL_USB_PASSTHROUGH=1`; default-on still pending Phase 2e external security sign-off + real-hardware verification. - **Observability (Prometheus + OpenTelemetry)** — stdlib-only `Counter` / `Gauge` / `Histogram` registry with a tiny built-in HTTP exporter on `/metrics`, plus an OpenTelemetry-compatible tracer that upgrades to real OTel spans when the SDK is installed. The executor and agent loop emit `autocontrol_action_calls_total{action,outcome}`, `autocontrol_action_duration_seconds`, and `autocontrol_agent_steps_total{tool,outcome}` automatically — drop the URL into a Prometheus scrape config and you have a Grafana dashboard with zero per-script wiring. @@ -546,8 +547,8 @@ ac.run_from_description("open Notepad and type hello", executor=executor) | `AUTOCONTROL_LLM_BACKEND` | `anthropic` to force a backend | | `AUTOCONTROL_LLM_MODEL` | Override the default model (e.g. `claude-opus-4-7`) | -GUI: **LLM Planner** tab — description box, `QThread`-backed *Plan* -button, action-list preview, and a *Run plan* button. +GUI: **LLM Planner** tab — description box and action-list preview; +*Plan* (`QThread`-backed) and *Run plan* live in the window's Actions menu. ### Runtime Variables & Control Flow @@ -575,7 +576,8 @@ commands, scripts can drive themselves from data without Python glue: `AC_if_var` operators: `eq`, `ne`, `lt`, `le`, `gt`, `ge`, `contains`, `startswith`, `endswith`. GUI: **Variables** tab — live view of -`executor.variables` with single-set, JSON seed, and clear-all controls. +`executor.variables`; single-set, JSON seed, and clear-all run from the +window's Actions menu. ### Remote Desktop @@ -1099,8 +1101,9 @@ for run in default_history_store.list_runs(limit=20): print(run.id, run.source, run.status, run.artifact_path) ``` -The GUI **Run History** tab exposes filter/refresh/clear and -double-click-to-open on the artifact column. +The GUI **Run History** tab shows the runs table with +double-click-to-open on the artifact column; filter, refresh, and +clear run from the window's Actions menu. ### Report Generation @@ -1357,6 +1360,16 @@ Or from the command line: python -m je_auto_control ``` +The main window is menu-driven: tabs hold only their inputs, tables, +and result views, and every tab's commands live in the window-level +**Actions** menu, which rebuilds for the active tab. **View → Tabs** +shows or hides any of the ~48 registered tabs, grouped by category +(Core / Editing / Detection & Vision / Automation Engines / System); +the default layout opens with just Record, Script Builder, and Remote +Desktop. **View → Text Size** offers auto/preset font scaling, and the +**Language** menu (English / 繁體中文 / 简体中文 / 日本語) retranslates +the whole window live. + --- ## Command-Line Interface @@ -1438,6 +1451,11 @@ python -m pytest test/integrated_test/ ### Project Links +- [Capability and platform matrix](docs/CAPABILITY_MATRIX.md) +- [Public API lifecycle and deprecation policy](docs/API_LIFECYCLE.md) +- [Security policy](SECURITY.md) +- [Compatibility changelog](CHANGELOG.md) + - **Homepage**: https://github.com/Intergration-Automation-Testing/AutoControl - **Documentation**: https://autocontrol.readthedocs.io/en/latest/ - **PyPI**: https://pypi.org/project/je_auto_control/ diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index a1113591..849485fc 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -97,9 +97,10 @@ - **WebRTC 包监测** — 由既有 WebRTC stats 轮询喂入的进程级 `StatsSnapshot` 滚动窗口(默认 600 条 / 1 Hz 约 10 分钟)。对 RTT、FPS、bitrate、丢包率、jitter 各回 `last/min/max/avg/p95` - **USB 设备列举** — 只读的跨平台 USB 设备列举。优先尝试 pyusb(libusb);若无则退回平台特定命令(Windows `Get-PnpDevice`、macOS `system_profiler`、Linux `/sys/bus/usb/devices`)。第二阶段 passthrough 构建于此(见下) - **系统诊断** — 一键"目前正常吗?"探测:平台、可选依赖包、executor 命令数、审计链、截图、鼠标、磁盘空间、REST registry。CLI 全绿 exit 0/否则 1;REST `/diagnose`;按严重度上色的 GUI 分页 +- **稳定 API 与失败诊断包** — 给新集成用的版本化、延迟加载 `je_auto_control.api` 门面(`execute_action`、`generate_code`、`run_diagnostics`、failure bundles),附[生命周期政策](../docs/API_LIFECYCLE.md)。便携式 `autocontrol.failure-bundle/v1` 诊断 ZIP:manifest + 已脱敏的 context/events/log 尾段、可选截图与诊断、best-effort 收集器、原子写入。CLI `je_auto_control failure-bundle out.zip`;`codegen --failure-bundle` 让生成的 pytest 自动包上失败诊断 - **USB Hotplug 事件** — 轮询式 hotplug 监测(`UsbHotplugWatcher`),含 bounded ring buffer 与带序号的事件;`GET /usb/events?since=N` 让晚加入的订阅者补上进度。USB 分页有自动刷新切换钮。 - **OpenAPI 3.1 + Swagger UI** — `GET /openapi.json`(auth-gated,从活的路由表生成)+ `GET /docs`(浏览器版 Swagger UI 含 bearer token 栏)。CI 上有 drift 测试,新加路由忘记写 metadata 会被拦下。 -- **配置包导出/导入** — 单一 JSON 文件,导出/导入用户配置(admin hosts、address book、trusted viewers、known hosts、host service、IDs)。原子写入加 `.bak.<时间戳>` 备份;CLI `python -m je_auto_control.utils.config_bundle export|import`;`POST /config/{export,import}`;REST API 分页有按钮。 +- **配置包导出/导入** — 单一 JSON 文件,导出/导入用户配置(admin hosts、address book、trusted viewers、known hosts、host service、IDs)。原子写入加 `.bak.<时间戳>` 备份;CLI `python -m je_auto_control.utils.config_bundle export|import`;`POST /config/{export,import}`;REST API 分页的导出/导入命令位于窗口的 Actions 菜单。 - **USB Passthrough(需主动启用)** — 让远端 viewer 使用实体插在 host 上的 USB 设备,走 WebRTC `usb` DataChannel。Wire-level 协议(11 个 opcode 含 `RESUME`、CREDIT 流量控制、16 KiB payload 上限,超量传输以 EOF 分片)。八个原始未决问题全部解决:可靠有序 channel、LIST 走 channel(ACL 过滤)、per-claim credit、Linux kernel driver detach/reattach、ACL **HMAC-SHA256 完整性**(篡改 fail-closed;密钥可插拔 — Windows DPAPI 或 passphrase vault)。**Backend:**`LibusbBackend`(production)、`WinusbBackend`(ctypes)、`IokitBackend`(原生 IOKit 列举 + libusb 传输)— Windows/macOS *硬件未验证*;`default_passthrough_backend()` 依 OS 自动挑。Viewer 端阻塞式 client(`control/bulk/interrupt_transfer`、`list_devices`、`resume`);in-process `UsbLoopback` 让同机可走完整堆栈 share+use。**已接入 WebRTC** host/viewer(`viewer.usb_client()`)并含断线可续租的 **resume token**。持久化 ACL(默认 deny、mode 0600),含 host 端 prompt 对话框、滥用 **rate-limit / lockout** 与可检测篡改审计整合。五个驱动面:AnyDesk 风 **GUI 面板**(分享 + ACL 允许/封锁 + 本机/远端使用)、`AC_usb_*` executor 命令(JSON / socket / 调度器)、**REST** `/usb/...`、一级 **MCP** `ac_usb_*` 工具、以及 Python API。默认 off — 用 `enable_usb_passthrough(True)` 或 `JE_AUTOCONTROL_USB_PASSTHROUGH=1` 启用;默认启用仍待 Phase 2e 外部安全签核 + 实机硬件验证。 --- @@ -529,7 +530,7 @@ ac.run_from_description("打开记事本并输入 hello", executor=executor) | `AUTOCONTROL_LLM_BACKEND` | 强制指定 `anthropic` | | `AUTOCONTROL_LLM_MODEL` | 覆盖默认模型(如 `claude-opus-4-7`) | -GUI:**LLM Planner** 分页 — 描述输入框、`QThread` 后台执行的 *Plan* 按钮、预览指令清单,以及 *Run plan* 按钮。 +GUI:**LLM Planner** 分页 — 描述输入框与指令清单预览;*Plan*(`QThread` 后台执行)与 *Run plan* 位于窗口的 Actions 菜单。 ### 运行期变量与流程控制 @@ -1004,8 +1005,8 @@ for run in default_history_store.list_runs(limit=20): print(run.id, run.source, run.status, run.artifact_path) ``` -GUI **执行历史** 标签页提供筛选 / 刷新 / 清除功能,并可双击截图列打开 -附件。 +GUI **执行历史** 标签页显示运行记录表格,可双击截图列打开附件;筛选 / +刷新 / 清除命令位于窗口的 Actions 菜单。 ### 报告生成 @@ -1237,6 +1238,13 @@ je_auto_control.start_autocontrol_gui() python -m je_auto_control ``` +主窗口采用菜单驱动设计:标签页只保留输入字段、表格与结果视图,每个 +标签页的命令都集中在窗口级的 **Actions** 菜单,会随当前标签页动态重建。 +**View → Tabs** 可按分类(核心 / 编辑 / 检测与视觉 / 自动化引擎 / 系统) +显示或隐藏约 48 个已注册标签页;默认布局只打开录制、脚本构建器与远程 +桌面三个标签页。**View → Text Size** 提供自动/预设字号,**Language** +菜单(English / 繁體中文 / 简体中文 / 日本語)可即时切换整个窗口的语言。 + --- ## 命令行界面 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 6812e988..a3361bf0 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -97,9 +97,10 @@ - **WebRTC 封包監測** — 由既有 WebRTC stats 輪詢餵入的程序級 `StatsSnapshot` 滾動視窗(預設 600 筆 / 1 Hz 約 10 分鐘)。對 RTT、FPS、bitrate、封包遺失、jitter 各回 `last/min/max/avg/p95` - **USB 裝置列舉** — 唯讀的跨平台 USB 裝置列舉。優先嘗試 pyusb(libusb);若無則退回平台特定指令(Windows `Get-PnpDevice`、macOS `system_profiler`、Linux `/sys/bus/usb/devices`)。第二階段 passthrough 建構於此(見下) - **系統診斷** — 一鍵「目前正常嗎?」探測:平台、選用相依套件、executor 指令數、稽核鏈、截圖、滑鼠、硬碟空間、REST registry。CLI 全綠 exit 0/否則 1;REST `/diagnose`;依嚴重度上色的 GUI 分頁 +- **穩定 API 與失敗診斷包** — 給新整合用的版本化、延遲載入 `je_auto_control.api` 門面(`execute_action`、`generate_code`、`run_diagnostics`、failure bundles),附[生命週期政策](../docs/API_LIFECYCLE.md)。可攜式 `autocontrol.failure-bundle/v1` 診斷 ZIP:manifest + 已遮罩的 context/events/log 尾段、可選截圖與診斷、best-effort 收集器、原子寫入。CLI `je_auto_control failure-bundle out.zip`;`codegen --failure-bundle` 讓產生的 pytest 自動包上失敗診斷 - **USB Hotplug 事件** — 輪詢式 hotplug 監測(`UsbHotplugWatcher`),含 bounded ring buffer 與帶序號的事件;`GET /usb/events?since=N` 讓晚加入的訂閱者補上進度。USB 分頁有自動更新切換鈕。 - **OpenAPI 3.1 + Swagger UI** — `GET /openapi.json`(auth-gated,從活的路由表生成)+ `GET /docs`(瀏覽器版 Swagger UI 含 bearer token 列)。CI 上有 drift 測試,新加路由忘記寫 metadata 會被擋下。 -- **設定包匯出/匯入** — 單一 JSON 檔,匯出/匯入使用者設定(admin hosts、address book、trusted viewers、known hosts、host service、IDs)。原子寫入加 `.bak.<時間戳>` 備份;CLI `python -m je_auto_control.utils.config_bundle export|import`;`POST /config/{export,import}`;REST API 分頁有按鈕。 +- **設定包匯出/匯入** — 單一 JSON 檔,匯出/匯入使用者設定(admin hosts、address book、trusted viewers、known hosts、host service、IDs)。原子寫入加 `.bak.<時間戳>` 備份;CLI `python -m je_auto_control.utils.config_bundle export|import`;`POST /config/{export,import}`;REST API 分頁的匯出/匯入指令位於視窗的 Actions 選單。 - **USB Passthrough(需主動啟用)** — 讓遠端 viewer 使用實體插在 host 上的 USB 裝置,走 WebRTC `usb` DataChannel。Wire-level 協定(11 個 opcode 含 `RESUME`、CREDIT 流量控制、16 KiB payload 上限,超量傳輸以 EOF 分片)。八個原始未決問題全部解決:可靠有序 channel、LIST 走 channel(ACL 過濾)、per-claim credit、Linux kernel driver detach/reattach、ACL **HMAC-SHA256 完整性**(竄改 fail-closed;金鑰可插拔 — Windows DPAPI 或 passphrase vault)。**Backend:**`LibusbBackend`(production)、`WinusbBackend`(ctypes)、`IokitBackend`(原生 IOKit 列舉 + libusb 傳輸)— Windows/macOS *硬體未驗證*;`default_passthrough_backend()` 依 OS 自動挑。Viewer 端阻塞式 client(`control/bulk/interrupt_transfer`、`list_devices`、`resume`);in-process `UsbLoopback` 讓同機可走完整堆疊 share+use。**已接入 WebRTC** host/viewer(`viewer.usb_client()`)並含斷線可續租的 **resume token**。持久化 ACL(預設 deny、mode 0600),含 host 端 prompt 對話框、濫用 **rate-limit / lockout** 與可偵測竄改稽核整合。五個驅動面:AnyDesk 風 **GUI 面板**(分享 + ACL 允許/封鎖 + 本機/遠端使用)、`AC_usb_*` executor 指令(JSON / socket / 排程器)、**REST** `/usb/...`、一級 **MCP** `ac_usb_*` 工具、以及 Python API。預設 off — 用 `enable_usb_passthrough(True)` 或 `JE_AUTOCONTROL_USB_PASSTHROUGH=1` 開啟;預設啟用仍待 Phase 2e 外部安全簽核 + 實機硬體驗證。 --- @@ -529,7 +530,7 @@ ac.run_from_description("開記事本,輸入 hello", executor=executor) | `AUTOCONTROL_LLM_BACKEND` | 強制指定 `anthropic` | | `AUTOCONTROL_LLM_MODEL` | 覆寫預設模型(如 `claude-opus-4-7`) | -GUI:**LLM Planner** 分頁 — 描述輸入框、`QThread` 背景執行的 *Plan* 按鈕、預覽指令清單,以及 *Run plan* 按鈕。 +GUI:**LLM Planner** 分頁 — 描述輸入框與指令清單預覽;*Plan*(`QThread` 背景執行)與 *Run plan* 位於視窗的 Actions 選單。 ### 執行期變數與流程控制 @@ -1004,8 +1005,8 @@ for run in default_history_store.list_runs(limit=20): print(run.id, run.source, run.status, run.artifact_path) ``` -GUI **執行歷史** 分頁提供篩選 / 更新 / 清除功能,並可雙擊截圖欄位開啟 -附件。 +GUI **執行歷史** 分頁顯示執行紀錄表格,可雙擊截圖欄位開啟附件;篩選 / +更新 / 清除指令位於視窗的 Actions 選單。 ### 報告產生 @@ -1237,6 +1238,13 @@ je_auto_control.start_autocontrol_gui() python -m je_auto_control ``` +主視窗採選單驅動設計:分頁只保留輸入欄位、表格與結果檢視,每個分頁的 +指令都集中在視窗層級的 **Actions** 選單,會隨當前分頁動態重建。 +**View → Tabs** 可依分類(核心 / 編輯 / 偵測與視覺 / 自動化引擎 / 系統) +顯示或隱藏約 48 個已註冊分頁;預設版面只開啟錄製、腳本建構器與遠端桌面 +三個分頁。**View → Text Size** 提供自動/預設字級,**Language** 選單 +(English / 繁體中文 / 简体中文 / 日本語)可即時切換整個視窗的語系。 + --- ## 命令列介面 diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 519be585..ba7e4e4b 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,20 @@ # 本次更新 — AutoControl +## 本次更新 (2026-07-03) — 稳定 API、失败诊断包与发布工程 + +给新集成用的版本化入口点、便携式失败诊断格式,以及强化后的发布管线。完整参考:[`docs/API_LIFECYCLE.md`](../docs/API_LIFECYCLE.md) 与 [`docs/CAPABILITY_MATRIX.md`](../docs/CAPABILITY_MATRIX.md)。 + +- **稳定 `je_auto_control.api` 门面**:小巧、延迟加载、有类型的命名空间(`execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、failure bundles),新的使用者导入核心自动化时不必连带加载数百个可选集成。受书面生命周期政策管辖——移除稳定 API 需要弃用警告加两个 minor 版本——并由 `utils/deprecation.deprecated` 提供一致、带元数据的警告。CI 以 mypy 检查此接口类型,并在 Windows/Ubuntu/macOS × Python 3.10/3.14 矩阵上冒烟测试导入。 +- **失败诊断包**(`create_failure_bundle` / `failure_bundle_on_error`,CLI `je_auto_control failure-bundle out.zip`):一个原子写入、自足的 `autocontrol.failure-bundle/v1` ZIP,用于诊断失败的运行——含运行环境信息的 manifest、已脱敏的 error/context/events、已脱敏的 log 尾段、可选截图与诊断报告、opt-in 附件。收集器为 best-effort:截图或诊断探测坏掉会记进 `collector_failures` 而不是丢失整个诊断包。`codegen --failure-bundle` 让生成的 pytest 流程自动封存自己的失败证据。秘密脱敏现在也会掩蔽明确的 `key=value` / `Authorization: Bearer` 凭证语法,不论其熵值高低。 +- **发布工程**:发布从 push-to-main 改为不可变的 `v*` 标签——新的 `release.yml` 验证标签与包版本一致、构建、冒烟测试 wheel、附上构建来源证明(provenance attestation),并经 PyPI Trusted Publishing 发布。`quality.yml` 新增 dependency review、覆盖率下限(fail-under 35,分支覆盖)与稳定 API 的 mypy 关卡;新的 platform-smoke workflow 在三个操作系统上演练稳定 API。 +- **项目文档**:新增 [`SECURITY.md`](../SECURITY.md)(私密安全通报、响应时限、操作默认值)、[`CHANGELOG.md`](../CHANGELOG.md)(Keep-a-Changelog 兼容性记录)、API 生命周期政策与能力/平台支持矩阵。Sphinx 索引补齐 v182–v223 两种语言的功能文档。 + +## 本次更新 (2026-07-02) — 菜单驱动 GUI:Actions 菜单取代标签页内按钮 + +每个标签页的命令现在集中在一个可预期的位置。窗口菜单栏新增动态 **Actions** 菜单,会随当前标签页重建;标签页只保留输入字段、表格与结果/状态视图,不再是一排排按钮。完整参考:[`docs/source/Zh/doc/new_features/v223_features_doc.rst`](../docs/source/Zh/doc/new_features/v223_features_doc.rst)。 + +- **窗口级 Actions 菜单**:核心标签页在注册时声明命令;功能标签页提供 `menu_actions()` 挂钩,返回 `(label_key, handler)` 配对。48 个已注册标签页中有 46 个以此方式呈现命令——Script Builder 与 Remote Desktop 刻意保留交互式面板布局,菜单在该处显示占位信息。窗口级菜单无法取代的按钮维持原位(堆叠触发器表单内的逐页浏览按钮、随可见性切换的数据源浏览按钮、有状态的自动刷新复选框)。无头回归测试守护此契约,标签页不可能默默失去其命令。 + ## 本次更新 (2026-06-24) — 扩充 UIA 控制模式(展开 / 选取 / 范围 / 滚动) 以原生模式驱动树节点、列表/下拉项目、滑块与滚动,而非像素猜测。完整参考:[`docs/source/Zh/doc/new_features/v181_features_doc.rst`](../docs/source/Zh/doc/new_features/v181_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index bfd2f407..3ba94c5a 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,20 @@ # 本次更新 — AutoControl +## 本次更新 (2026-07-03) — 穩定 API、失敗診斷包與發佈工程 + +給新整合用的版本化進入點、可攜式失敗診斷格式,以及強化後的發佈管線。完整參考:[`docs/API_LIFECYCLE.md`](../docs/API_LIFECYCLE.md) 與 [`docs/CAPABILITY_MATRIX.md`](../docs/CAPABILITY_MATRIX.md)。 + +- **穩定 `je_auto_control.api` 門面**:小巧、延遲載入、有型別的命名空間(`execute_action`、`execute_action_with_vars`、`generate_code`、`run_diagnostics`、failure bundles),新的使用者匯入核心自動化時不必連帶載入數百個選用整合。受書面生命週期政策管轄——移除穩定 API 需要棄用警告加兩個 minor 版本——並由 `utils/deprecation.deprecated` 提供一致、帶中繼資料的警告。CI 以 mypy 檢查此介面型別,並在 Windows/Ubuntu/macOS × Python 3.10/3.14 矩陣上煙霧測試匯入。 +- **失敗診斷包**(`create_failure_bundle` / `failure_bundle_on_error`,CLI `je_auto_control failure-bundle out.zip`):一個原子寫入、自足的 `autocontrol.failure-bundle/v1` ZIP,用於診斷失敗的執行——含執行環境資訊的 manifest、已遮罩的 error/context/events、已遮罩的 log 尾段、可選截圖與診斷報告、opt-in 附件。收集器為 best-effort:截圖或診斷探測壞掉會記進 `collector_failures` 而不是丟失整個診斷包。`codegen --failure-bundle` 讓產生的 pytest 流程自動封存自己的失敗證據。秘密遮罩現在也會遮蔽明確的 `key=value` / `Authorization: Bearer` 憑證語法,不論其熵值高低。 +- **發佈工程**:發佈從 push-to-main 改為不可變的 `v*` 標籤——新的 `release.yml` 驗證標籤與套件版本一致、建置、煙霧測試 wheel、附上建置來源證明(provenance attestation),並經 PyPI Trusted Publishing 發佈。`quality.yml` 新增 dependency review、覆蓋率下限(fail-under 35,分支覆蓋)與穩定 API 的 mypy 關卡;新的 platform-smoke workflow 在三個作業系統上演練穩定 API。 +- **專案文件**:新增 [`SECURITY.md`](../SECURITY.md)(私密安全通報、回應時限、操作預設值)、[`CHANGELOG.md`](../CHANGELOG.md)(Keep-a-Changelog 相容性紀錄)、API 生命週期政策與能力/平台支援矩陣。Sphinx 索引補齊 v182–v223 兩種語言的功能文件。 + +## 本次更新 (2026-07-02) — 選單驅動 GUI:Actions 選單取代分頁內按鈕 + +每個分頁的指令現在集中在一個可預期的位置。視窗選單列新增動態 **Actions** 選單,會隨當前分頁重建;分頁只保留輸入欄位、表格與結果/狀態檢視,不再是一排排按鈕。完整參考:[`docs/source/Zh/doc/new_features/v223_features_doc.rst`](../docs/source/Zh/doc/new_features/v223_features_doc.rst)。 + +- **視窗層級 Actions 選單**:核心分頁在註冊時宣告指令;功能分頁提供 `menu_actions()` 掛鉤,回傳 `(label_key, handler)` 配對。48 個已註冊分頁中有 46 個以此方式呈現指令——Script Builder 與 Remote Desktop 刻意保留互動式面板版面,選單在該處顯示佔位訊息。視窗層級選單無法取代的按鈕維持原位(堆疊觸發器表單內的逐頁瀏覽按鈕、隨可見性切換的資料來源瀏覽按鈕、有狀態的自動更新核取方塊)。無頭迴歸測試守護此契約,分頁不可能默默失去其指令。 + ## 本次更新 (2026-06-24) — 擴充 UIA 控制模式(展開 / 選取 / 範圍 / 捲動) 以原生模式驅動樹節點、清單/下拉項目、滑桿與捲動,而非像素猜測。完整參考:[`docs/source/Zh/doc/new_features/v181_features_doc.rst`](../docs/source/Zh/doc/new_features/v181_features_doc.rst)。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..96944596 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security policy + +## Supported versions + +Security fixes are provided for the latest PyPI release and the `main` branch. +Experimental capabilities listed in the capability matrix receive best-effort +fixes and are not covered by compatibility guarantees. + +## Reporting a vulnerability + +Do not open a public issue. Use GitHub's **Report a vulnerability** private +advisory for this repository. Include affected versions, platform, impact, +reproduction steps, and any suggested mitigation. Avoid attaching credentials, +tokens, unredacted logs, or screenshots containing personal data. + +The maintainers aim to acknowledge a report within 3 business days, provide an +initial assessment within 7 business days, and coordinate disclosure after a +fix is available. There is no bug-bounty promise. + +## Operational defaults + +Network listeners must bind to loopback unless explicitly configured, remote +control and USB passthrough remain opt-in, and diagnostic bundles redact secret +values by default. Operators remain responsible for access control, TLS, log +retention, and reviewing attachments before sharing them. diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 53d55541..833b65a0 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,10 +1,21 @@ # What's New — AutoControl +## What's new (2026-07-03) + +### Stable API, Failure Bundles, and Release Engineering + +A versioned entry point for new integrations, a portable failure-diagnostics format, and a hardened release pipeline. Full reference: [`docs/API_LIFECYCLE.md`](docs/API_LIFECYCLE.md) and [`docs/CAPABILITY_MATRIX.md`](docs/CAPABILITY_MATRIX.md). + +- **Stable `je_auto_control.api` façade**: small, lazy, typed namespace (`execute_action`, `execute_action_with_vars`, `generate_code`, `run_diagnostics`, failure bundles) so new consumers can import core automation without eagerly loading hundreds of optional integrations. Governed by a written lifecycle policy — stable removals need a deprecation warning plus two minor releases — with `utils/deprecation.deprecated` supplying consistent, metadata-carrying warnings. CI type-checks this surface with mypy and smoke-imports it on a Windows/Ubuntu/macOS × Python 3.10/3.14 matrix. +- **Failure bundles** (`create_failure_bundle` / `failure_bundle_on_error`, CLI `je_auto_control failure-bundle out.zip`): one atomic, self-contained `autocontrol.failure-bundle/v1` ZIP for diagnosing a failed run — manifest with runtime info, redacted error/context/events, redacted log tail, optional screenshot and diagnostics report, opt-in attachments. Collectors are best-effort: a broken screen grab or diagnostics probe is recorded in `collector_failures` instead of losing the bundle. `codegen --failure-bundle` wraps generated pytest flows so every generated test archives its own failure evidence. Secret redaction now also masks explicit `key=value` / `Authorization: Bearer` credential syntax regardless of entropy. +- **Release engineering**: publishing moves from push-to-main to immutable `v*` tags — the new `release.yml` verifies the tag matches the package version, builds, smoke-tests the wheel, attests build provenance, and publishes via PyPI Trusted Publishing. `quality.yml` gains dependency review, a coverage floor (fail-under 35, branch coverage), and a mypy gate on the stable API; a new platform-smoke workflow exercises the stable API on all three OSes. +- **Project docs**: new [`SECURITY.md`](SECURITY.md) (private-advisory reporting, response targets, operational defaults), [`CHANGELOG.md`](CHANGELOG.md) (Keep-a-Changelog compatibility record), API lifecycle policy, and a capability/platform support matrix. The Sphinx indexes catch up on v182–v223 feature docs in both languages. + ## What's new (2026-07-02) ### Menu-Driven GUI: the Actions Menu Replaces In-Tab Buttons -Every tab's commands now live in one predictable place. The window menu bar gains a dynamic **Actions** menu that rebuilds for the active tab; tabs keep only their inputs, tables, and result/status views instead of rows of buttons. +Every tab's commands now live in one predictable place. The window menu bar gains a dynamic **Actions** menu that rebuilds for the active tab; tabs keep only their inputs, tables, and result/status views instead of rows of buttons. Full reference: [`docs/source/Eng/doc/new_features/v223_features_doc.rst`](docs/source/Eng/doc/new_features/v223_features_doc.rst). - **Window-level Actions menu**: core tabs declare their commands at registration; feature tabs expose a `menu_actions()` hook returning `(label_key, handler)` pairs. 46 of 48 registered tabs now surface their commands this way — Script Builder and Remote Desktop intentionally keep their interactive panel layouts, and the menu shows a placeholder there. Buttons a window-level menu cannot replace stay in place (per-page browse buttons inside stacked trigger forms, the visibility-toggled data-source browse button, stateful auto-refresh checkboxes). A headless regression test guards the contract so no tab can silently lose its commands. diff --git a/benchmarks/core_latency.py b/benchmarks/core_latency.py new file mode 100644 index 00000000..bdb72271 --- /dev/null +++ b/benchmarks/core_latency.py @@ -0,0 +1,31 @@ +"""Repeatable smoke benchmark for stable headless entry points.""" +import json +import statistics +import time + + +def _measure(callable_, repeats=20): + samples = [] + for _ in range(repeats): + start = time.perf_counter() + callable_() + samples.append((time.perf_counter() - start) * 1000) + ordered = sorted(samples) + return { + "median_ms": round(statistics.median(samples), 3), + "p95_ms": round(ordered[max(0, int(len(ordered) * .95) - 1)], 3), + } + + +def main(): + import je_auto_control.api as ac + results = { + "diagnostics_ms": _measure(ac.run_diagnostics, repeats=5), + "codegen_ms": _measure( + lambda: ac.generate_code([["AC_screen_size"]]), repeats=20), + } + print(json.dumps(results, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/dev_requirements.txt b/dev_requirements.txt index 8ac5598d..26b96b0c 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -20,3 +20,5 @@ bandit==1.9.4 pytest==9.0.3 pytest-timeout==2.4.0 pytest-rerunfailures==15.1 +pytest-cov>=6.0 +mypy>=1.15 diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md new file mode 100644 index 00000000..11058c61 --- /dev/null +++ b/docs/API_LIFECYCLE.md @@ -0,0 +1,17 @@ +# Public API lifecycle + +The supported entry point for new integrations is `je_auto_control.api`. +Everything reachable only through `je_auto_control.utils` is internal unless a +document explicitly says otherwise. The historical top-level package remains +available for compatibility but is not expanded with new integrations. + +- Stable API removal requires a deprecation warning and two minor releases. +- Beta API removal requires one release note and one minor release. +- Experimental API may change in any release and must be labelled as such. +- Deprecations state the version introduced, planned removal version, and + replacement. Use `je_auto_control.utils.deprecation.deprecated`. +- Breaking changes and migrations are recorded in `CHANGELOG.md`. + +The project remains pre-1.0. A 1.0 release requires passing stable capability +tests on every claimed platform, documented recovery/diagnostic behavior, and +no unresolved critical security advisories. diff --git a/docs/CAPABILITY_MATRIX.md b/docs/CAPABILITY_MATRIX.md new file mode 100644 index 00000000..e4c7e063 --- /dev/null +++ b/docs/CAPABILITY_MATRIX.md @@ -0,0 +1,23 @@ +# Capability matrix + +Status meanings: **stable** is compatibility-supported, **beta** is suitable +for evaluation with documented limitations, and **experimental** may change +without a compatibility window. + +| Capability | Status | Windows | Linux X11 | Linux Wayland | macOS | +|---|---|---:|---:|---:|---:| +| Mouse, keyboard, screenshot | stable | CI | CI/Xvfb | partial | implementation | +| JSON executor and variables | stable | CI | CI | CI | platform-neutral | +| Image and anchor locators | beta | CI | CI | screenshot-only | implementation | +| Accessibility locator | beta | CI | backend tests | unavailable | backend tests | +| Recorder | beta | CI | implementation | unavailable | unavailable | +| Reports, trace, failure bundle | stable | CI | CI | CI | platform-neutral | +| REST, MCP, scheduler | beta | CI | CI | CI | platform-neutral | +| Remote desktop / WebRTC | beta | tests | tests | tests | tests | +| Android and iOS bridges | experimental | mocked CI | mocked CI | mocked CI | mocked CI | +| LLM/VLM agents | experimental | fake-backend CI | fake-backend CI | fake-backend CI | fake-backend CI | +| USB passthrough | experimental | hardware-unverified | backend tests | backend tests | hardware-unverified | + +“Implementation” means code exists but the repository does not currently run a +real OS runner for it. It must not be interpreted as a production guarantee. +Hardware-backed results and known limitations should be attached to releases. diff --git a/docs/source/Eng/doc/new_features/v223_features_doc.rst b/docs/source/Eng/doc/new_features/v223_features_doc.rst new file mode 100644 index 00000000..ef5264f6 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v223_features_doc.rst @@ -0,0 +1,48 @@ +Menu-Driven GUI: the Actions Menu Replaces In-Tab Buttons +========================================================= + +The main window is redesigned around a menu bar and a low-button tab layout. +Tabs keep only their inputs, tables, and result/status views; every tab's +commands move to one predictable place — a window-level **Actions** menu that +rebuilds for the active tab. + +The Actions menu +---------------- + +Two ways a tab surfaces its commands: + +* **Registry actions** — core tabs (Auto Click, Screenshot, Image Detection, + Record, Script Executor, Report) declare ``(label_key, handler)`` pairs when + they are registered in ``gui/main_widget.py``. +* **The** ``menu_actions()`` **hook** — feature tabs expose a + ``menu_actions()`` method returning the same ``[(label_key, handler), ...]`` + shape; the menu bar queries the active tab and renders whatever it returns. + +46 of 48 registered tabs surface their commands this way. **Script Builder** +and **Remote Desktop** intentionally keep their interactive panel layouts, and +the Actions menu shows a placeholder there. Controls a window-level menu cannot +replace stay in place: per-page browse buttons inside stacked trigger forms, +the visibility-toggled data-source browse button, and stateful auto-refresh +checkboxes. + +The View menu +------------- + +* **View → Tabs** shows or hides any registered tab, grouped by category + (Core / Editing / Detection & Vision / Automation Engines / System). The + default layout opens with just Record, Script Builder, and Remote Desktop; + everything else is one menu click away. Tabs are closable — closing one is + the same as unchecking it in the View menu. +* **View → Text Size** offers auto (screen-height based) and preset font + sizes applied live. + +The contract test +----------------- + +``test/unit_test/headless/test_actions_menu_gui.py`` guards the contract: every +registered tab must expose commands through registry actions or a +``menu_actions()`` hook (the two exempt tabs aside), and every entry must be a +non-empty ``label_key`` string paired with a callable. A new tab that forgets +the hook fails CI instead of silently shipping with no reachable commands. The +probe runs the full widget construction in a subprocess so the Qt lifetime +cannot destabilise the rest of the headless suite. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 10f84a13..e3f679e6 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -204,6 +204,48 @@ Comprehensive guides for all AutoControl features. doc/new_features/v179_features_doc doc/new_features/v180_features_doc doc/new_features/v181_features_doc + doc/new_features/v182_features_doc + doc/new_features/v183_features_doc + doc/new_features/v184_features_doc + doc/new_features/v185_features_doc + doc/new_features/v186_features_doc + doc/new_features/v187_features_doc + doc/new_features/v188_features_doc + doc/new_features/v189_features_doc + doc/new_features/v190_features_doc + doc/new_features/v191_features_doc + doc/new_features/v192_features_doc + doc/new_features/v193_features_doc + doc/new_features/v194_features_doc + doc/new_features/v195_features_doc + doc/new_features/v196_features_doc + doc/new_features/v197_features_doc + doc/new_features/v198_features_doc + doc/new_features/v199_features_doc + doc/new_features/v200_features_doc + doc/new_features/v201_features_doc + doc/new_features/v202_features_doc + doc/new_features/v203_features_doc + doc/new_features/v204_features_doc + doc/new_features/v205_features_doc + doc/new_features/v206_features_doc + doc/new_features/v207_features_doc + doc/new_features/v208_features_doc + doc/new_features/v209_features_doc + doc/new_features/v210_features_doc + doc/new_features/v211_features_doc + doc/new_features/v212_features_doc + doc/new_features/v213_features_doc + doc/new_features/v214_features_doc + doc/new_features/v215_features_doc + doc/new_features/v216_features_doc + doc/new_features/v217_features_doc + doc/new_features/v218_features_doc + doc/new_features/v219_features_doc + doc/new_features/v220_features_doc + doc/new_features/v221_features_doc + doc/new_features/v222_features_doc + doc/new_features/v223_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/v223_features_doc.rst b/docs/source/Zh/doc/new_features/v223_features_doc.rst new file mode 100644 index 00000000..4b872c04 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v223_features_doc.rst @@ -0,0 +1,42 @@ +選單驅動 GUI:Actions 選單取代分頁內按鈕 +========================================= + +主視窗改以選單列與低按鈕分頁版面重新設計。分頁只保留輸入欄位、表格與 +結果/狀態檢視;每個分頁的指令移到一個可預期的位置——會隨當前分頁動態 +重建的視窗層級 **Actions** 選單。 + +Actions 選單 +------------ + +分頁有兩種方式呈現其指令: + +* **註冊時宣告**——核心分頁(自動點擊、截圖、影像偵測、錄製、腳本執行器、 + 報告)在 ``gui/main_widget.py`` 註冊時宣告 ``(label_key, handler)`` 配對。 +* ``menu_actions()`` **掛鉤**——功能分頁提供 ``menu_actions()`` 方法, + 回傳相同的 ``[(label_key, handler), ...]`` 形狀;選單列查詢當前分頁並 + 渲染其回傳內容。 + +48 個已註冊分頁中有 46 個以此方式呈現指令。**Script Builder** 與 +**Remote Desktop** 刻意保留其互動式面板版面,Actions 選單在這兩頁顯示 +佔位訊息。視窗層級選單無法取代的控制項則維持原位:堆疊觸發器表單內的 +逐頁瀏覽按鈕、隨可見性切換的資料來源瀏覽按鈕,以及有狀態的自動更新 +核取方塊。 + +View 選單 +--------- + +* **View → Tabs** 可依分類(核心 / 編輯 / 偵測與視覺 / 自動化引擎 / 系統) + 顯示或隱藏任一已註冊分頁。預設版面只開啟錄製、Script Builder 與遠端 + 桌面;其餘分頁一個選單點擊即可叫出。分頁可關閉——關閉等同於在 View + 選單取消勾選。 +* **View → Text Size** 提供自動(依螢幕高度)與預設字級,即時套用。 + +契約測試 +-------- + +``test/unit_test/headless/test_actions_menu_gui.py`` 守護此契約:每個已 +註冊分頁都必須透過註冊宣告或 ``menu_actions()`` 掛鉤呈現指令(兩個豁免 +分頁除外),且每個項目都必須是非空的 ``label_key`` 字串搭配可呼叫物件。 +新分頁若忘了掛鉤會直接讓 CI 失敗,而不是默默出貨一個沒有任何可觸及指令 +的分頁。探測程序在子行程中建構完整 widget,使 Qt 生命週期不會影響無頭 +測試套件的其餘部分。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 3c90f5b6..b857826f 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -204,6 +204,48 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v179_features_doc doc/new_features/v180_features_doc doc/new_features/v181_features_doc + doc/new_features/v182_features_doc + doc/new_features/v183_features_doc + doc/new_features/v184_features_doc + doc/new_features/v185_features_doc + doc/new_features/v186_features_doc + doc/new_features/v187_features_doc + doc/new_features/v188_features_doc + doc/new_features/v189_features_doc + doc/new_features/v190_features_doc + doc/new_features/v191_features_doc + doc/new_features/v192_features_doc + doc/new_features/v193_features_doc + doc/new_features/v194_features_doc + doc/new_features/v195_features_doc + doc/new_features/v196_features_doc + doc/new_features/v197_features_doc + doc/new_features/v198_features_doc + doc/new_features/v199_features_doc + doc/new_features/v200_features_doc + doc/new_features/v201_features_doc + doc/new_features/v202_features_doc + doc/new_features/v203_features_doc + doc/new_features/v204_features_doc + doc/new_features/v205_features_doc + doc/new_features/v206_features_doc + doc/new_features/v207_features_doc + doc/new_features/v208_features_doc + doc/new_features/v209_features_doc + doc/new_features/v210_features_doc + doc/new_features/v211_features_doc + doc/new_features/v212_features_doc + doc/new_features/v213_features_doc + doc/new_features/v214_features_doc + doc/new_features/v215_features_doc + doc/new_features/v216_features_doc + doc/new_features/v217_features_doc + doc/new_features/v218_features_doc + doc/new_features/v219_features_doc + doc/new_features/v220_features_doc + doc/new_features/v221_features_doc + doc/new_features/v222_features_doc + doc/new_features/v223_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/api/__init__.py b/je_auto_control/api/__init__.py new file mode 100644 index 00000000..4a4bc657 --- /dev/null +++ b/je_auto_control/api/__init__.py @@ -0,0 +1,22 @@ +"""Small, versioned entry points for new integrations. + +The historical top-level package remains compatible. New consumers should +prefer this namespace so importing core automation does not eagerly import +hundreds of optional integrations. +""" + +from je_auto_control.api.core import ( + FailureBundleOptions, + create_failure_bundle, + execute_action, + execute_action_with_vars, + generate_code, + failure_bundle_on_error, + run_diagnostics, +) + +__all__ = [ + "FailureBundleOptions", "create_failure_bundle", "execute_action", + "execute_action_with_vars", "failure_bundle_on_error", "generate_code", + "run_diagnostics", +] diff --git a/je_auto_control/api/core.py b/je_auto_control/api/core.py new file mode 100644 index 00000000..f57f54c8 --- /dev/null +++ b/je_auto_control/api/core.py @@ -0,0 +1,19 @@ +"""Stable, headless AutoControl API façade.""" + +from je_auto_control.utils.codegen.codegen import generate_code +from je_auto_control.utils.diagnostics import run_diagnostics +from je_auto_control.utils.executor.action_executor import ( + execute_action, + execute_action_with_vars, +) +from je_auto_control.utils.failure_bundle import ( + FailureBundleOptions, + create_failure_bundle, + failure_bundle_on_error, +) + +__all__ = [ + "FailureBundleOptions", "create_failure_bundle", "execute_action", + "execute_action_with_vars", "failure_bundle_on_error", "generate_code", + "run_diagnostics", +] diff --git a/je_auto_control/cli.py b/je_auto_control/cli.py index 4a59595d..6864efff 100644 --- a/je_auto_control/cli.py +++ b/je_auto_control/cli.py @@ -11,6 +11,7 @@ je_auto_control fmt script.json [--check] je_auto_control record out.json [--duration 5] je_auto_control codegen script.json [--target pytest] [-o test_flow.py] + je_auto_control failure-bundle failure.zip [--error "message"] je_auto_control version je_auto_control list-jobs je_auto_control start-server --port 9938 @@ -147,11 +148,13 @@ def cmd_codegen(args: argparse.Namespace) -> int: from je_auto_control.utils.json.json_file import read_action_json if args.output: generate_code_file(args.script, args.output, target=args.target, - name=args.name, style=args.style) + name=args.name, style=args.style, + failure_bundle=args.failure_bundle) sys.stderr.write(f"Wrote {args.target} code to {args.output}\n") else: code = generate_code(read_action_json(args.script), target=args.target, - name=args.name, style=args.style) + name=args.name, style=args.style, + failure_bundle=args.failure_bundle) sys.stdout.write(code) return 0 @@ -166,6 +169,25 @@ def cmd_version(_: argparse.Namespace) -> int: return 0 +def cmd_failure_bundle(args: argparse.Namespace) -> int: + """Collect a portable, redacted diagnostic archive.""" + from je_auto_control.utils.failure_bundle import ( + FailureBundleOptions, create_failure_bundle, + ) + context = json.loads(args.context) if args.context else {} + path = create_failure_bundle( + args.output, error=args.error, context=context, + options=FailureBundleOptions( + screenshot=not args.no_screenshot, + diagnostics=not args.no_diagnostics, + log_path=args.log, + attachments=tuple(args.attach or ()), + ), + ) + sys.stdout.write(path + "\n") + return 0 + + def cmd_list_jobs(_: argparse.Namespace) -> int: from je_auto_control.utils.scheduler.scheduler import default_scheduler jobs = default_scheduler.list_jobs() @@ -253,11 +275,26 @@ def build_parser() -> argparse.ArgumentParser: default="calls") p_codegen.add_argument("--name", default="recorded_flow") p_codegen.add_argument("-o", "--output", help="Write to file instead of stdout") + p_codegen.add_argument( + "--failure-bundle", action="store_true", + help="Wrap generated pytest in automatic failure diagnostics") p_codegen.set_defaults(func=cmd_codegen) p_version = sub.add_parser("version", help="Print the installed version") p_version.set_defaults(func=cmd_version) + p_bundle = sub.add_parser( + "failure-bundle", help="Create a redacted failure diagnostic ZIP") + p_bundle.add_argument("output") + p_bundle.add_argument("--error", help="Failure summary") + p_bundle.add_argument("--context", help="JSON object with run context") + p_bundle.add_argument("--log", help="Log file whose redacted tail is included") + p_bundle.add_argument("--attach", action="append", + help="Explicit attachment; may be repeated") + p_bundle.add_argument("--no-screenshot", action="store_true") + p_bundle.add_argument("--no-diagnostics", action="store_true") + p_bundle.set_defaults(func=cmd_failure_bundle) + p_jobs = sub.add_parser("list-jobs", help="List scheduler jobs") p_jobs.set_defaults(func=cmd_list_jobs) diff --git a/je_auto_control/gui/_auto_click_tab.py b/je_auto_control/gui/_auto_click_tab.py index 17d7d5a0..d6f9b042 100644 --- a/je_auto_control/gui/_auto_click_tab.py +++ b/je_auto_control/gui/_auto_click_tab.py @@ -5,6 +5,7 @@ QGroupBox, ) +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.wrapper.auto_control_keyboard import ( type_keyboard, hotkey, write, get_keyboard_keys_table, ) @@ -224,7 +225,10 @@ def _do_click(self): type_keyboard(key) if is_double: type_keyboard(key) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: + # AutoControlMouseException / AutoControlCantFindKeyException from a + # throwing backend must land here — otherwise timer.stop() is + # skipped and the auto-click QTimer fires the failing action forever. self.timer.stop() QMessageBox.warning(self, "Error", str(error)) @@ -237,7 +241,7 @@ def _get_mouse_pos(self): ) self.cursor_x_input.setText(str(x)) self.cursor_y_input.setText(str(y)) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) def _send_hotkey(self): @@ -245,7 +249,7 @@ def _send_hotkey(self): keys = [k.strip() for k in self.hotkey_input.text().split(",") if k.strip()] if keys: hotkey(keys) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) def _send_write(self): @@ -253,7 +257,7 @@ def _send_write(self): text = self.write_input.text() if text: write(text) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) def _send_scroll(self): @@ -261,5 +265,5 @@ def _send_scroll(self): val = int(self.scroll_value_input.text() or "3") direction = self.scroll_dir_combo.currentText() if self.scroll_dir_combo else "scroll_down" mouse_scroll(val, scroll_direction=direction) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) diff --git a/je_auto_control/gui/_report_tab.py b/je_auto_control/gui/_report_tab.py index f3049e7c..5c2318ce 100644 --- a/je_auto_control/gui/_report_tab.py +++ b/je_auto_control/gui/_report_tab.py @@ -4,6 +4,7 @@ QTextEdit, QVBoxLayout, QWidget, ) +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.generate_report.generate_html_report import generate_html_report from je_auto_control.utils.generate_report.generate_json_report import generate_json_report from je_auto_control.utils.generate_report.generate_xml_report import generate_xml_report @@ -59,7 +60,7 @@ def _gen_html(self): name = self.report_name_input.text() or "autocontrol_report" generate_html_report(name) self.report_result_text.setText(f"HTML report generated: {name}") - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.report_result_text.setText(f"Error: {error}") def _gen_json(self): @@ -67,7 +68,7 @@ def _gen_json(self): name = self.report_name_input.text() or "autocontrol_report" generate_json_report(name) self.report_result_text.setText(f"JSON report generated: {name}") - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.report_result_text.setText(f"Error: {error}") def _gen_xml(self): @@ -75,5 +76,5 @@ def _gen_xml(self): name = self.report_name_input.text() or "autocontrol_report" generate_xml_report(name) self.report_result_text.setText(f"XML report generated: {name}") - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.report_result_text.setText(f"Error: {error}") diff --git a/je_auto_control/gui/admin_console_tab.py b/je_auto_control/gui/admin_console_tab.py index 3692f542..183e44b1 100644 --- a/je_auto_control/gui/admin_console_tab.py +++ b/je_auto_control/gui/admin_console_tab.py @@ -189,6 +189,8 @@ def _on_refresh(self) -> None: worker.finished.connect(thread.quit) worker.failed.connect(thread.quit) thread.finished.connect(self._on_poll_thread_done) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) self._poll_thread = thread thread.start() @@ -237,6 +239,10 @@ def _refresh_thumbnails(self) -> None: worker.finished.connect(self._apply_thumbnails) worker.finished.connect(thread.quit) thread.finished.connect(self._on_thumb_thread_done) + # Without deleteLater the QThread (parented to self) and its worker + # accumulate one per poll tick — a fresh handle leak every interval. + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) self._thumb_thread = thread thread.start() diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index a0158c4c..6483303c 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -69,6 +69,7 @@ from je_auto_control.wrapper.auto_control_screen import screen_size, screenshot, get_pixel from je_auto_control.wrapper.auto_control_image import locate_all_image, locate_image_center, locate_and_click from je_auto_control.wrapper.auto_control_record import record, stop_record +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.executor.action_executor import execute_action, execute_files from je_auto_control.utils.json.json_file import read_action_json, write_action_json from je_auto_control.utils.file_process.get_dir_file_list import get_dir_files_as_list @@ -646,7 +647,7 @@ def _start_record(self): record() self._record_status_key = "record_recording" self._apply_record_status_label() - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) def _stop_record(self): @@ -655,7 +656,7 @@ def _stop_record(self): self._record_status_key = "record_idle" self._apply_record_status_label() self.record_list_text.setText(json.dumps(self._record_data, indent=2, ensure_ascii=False)) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) def _playback_record(self): @@ -664,7 +665,7 @@ def _playback_record(self): QMessageBox.warning(self, "Warning", "No recorded data") return execute_action(self._record_data) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) def _save_record(self): @@ -675,7 +676,7 @@ def _save_record(self): path, _ = QFileDialog.getSaveFileName(self, _t("save_record"), "", _JSON_FILE_FILTER) if path: write_action_json(path, self._record_data) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) def _load_record(self): @@ -684,7 +685,7 @@ def _load_record(self): if path: self._record_data = read_action_json(path) self.record_list_text.setText(json.dumps(self._record_data, indent=2, ensure_ascii=False)) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) # ========================================================================= @@ -727,7 +728,7 @@ def _browse_script(self): try: data = read_action_json(path) self.script_editor.setText(json.dumps(data, indent=2, ensure_ascii=False)) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.script_result_text.setText(f"Error loading: {error}") def _execute_script(self): @@ -738,7 +739,7 @@ def _execute_script(self): data = read_action_json(path) result = execute_action(data) self.script_result_text.setText(json.dumps(result, indent=2, default=str, ensure_ascii=False)) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.script_result_text.setText(f"Error: {error}") def _browse_script_dir(self): @@ -754,7 +755,7 @@ def _execute_dir(self): files = get_dir_files_as_list(path) result = execute_files(files) self.script_result_text.setText(json.dumps(result, indent=2, default=str, ensure_ascii=False)) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, RuntimeError) as error: self.script_result_text.setText(f"Error: {error}") def _execute_manual_script(self): diff --git a/je_auto_control/gui/main_window.py b/je_auto_control/gui/main_window.py index b99e7877..863f5424 100644 --- a/je_auto_control/gui/main_window.py +++ b/je_auto_control/gui/main_window.py @@ -47,6 +47,10 @@ def __init__(self) -> None: self._user_font_pt: int = 0 # 0 means auto-detect from screen self.apply_stylesheet(self, "dark_amber.xml") + # qt_material writes the theme into this window's stylesheet; capture it + # so _apply_font_pt can append the font rule instead of replacing (and + # thereby wiping) the theme. + self._theme_stylesheet: str = self.styleSheet() self._apply_font_pt(self._user_font_pt) self.setWindowTitle(_t("application_name", "AutoControlGUI")) @@ -170,8 +174,15 @@ def _detect_auto_font_pt(self) -> int: return 12 def _apply_font_pt(self, pt: int) -> None: + """Apply the font size on top of the active theme stylesheet. + + The theme lives in this window's stylesheet, so the font rule is + appended rather than assigned — assigning would replace (and wipe) the + qt_material theme on startup and on every text-size change. + """ effective = pt if pt > 0 else self._detect_auto_font_pt() - self.setStyleSheet(f"font-size: {effective}pt; font-family: 'Lato';") + font_rule = f"* {{ font-size: {effective}pt; font-family: 'Lato'; }}" + self.setStyleSheet(f"{self._theme_stylesheet}\n{font_rule}") def _on_text_size_selected(self) -> None: action = self.sender() diff --git a/je_auto_control/gui/presence_tab.py b/je_auto_control/gui/presence_tab.py index 7e79ac9a..844071ef 100644 --- a/je_auto_control/gui/presence_tab.py +++ b/je_auto_control/gui/presence_tab.py @@ -7,7 +7,7 @@ """ from typing import Optional -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtWidgets import ( QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, @@ -33,6 +33,10 @@ def _t(key: str) -> str: class PresenceTab(TranslatableMixin, QWidget): """Roster view + role controls for the multi-viewer presence registry.""" + # Emitted from the registry listener (which runs off the Qt thread); a + # queued connection bounces the refresh onto the GUI thread. + _registry_changed = Signal() + def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._tr_init() @@ -40,6 +44,7 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._table = QTableWidget(0, len(_COLUMNS)) self._status = QLabel() self._build_layout() + self._registry_changed.connect(self.refresh) # Listener fires on every change; the timer is a belt-and-braces # refresh in case a listener exception drops us off. self._registry.add_listener(self._on_registry_event) @@ -103,8 +108,10 @@ def _populate_row(self, index: int, row: ViewerPresence) -> None: def _on_registry_event(self, _viewer_id: str, _row: Optional[ViewerPresence]) -> None: - # Listener runs off the Qt thread; bounce onto the GUI loop. - QTimer.singleShot(0, self.refresh) + # Listener runs off the Qt thread, which has no event loop, so + # QTimer.singleShot would never fire. Emit a signal — Qt queues the + # refresh onto the GUI thread. + self._registry_changed.emit() def _selected_viewer_id(self) -> Optional[str]: row = self._table.currentRow() diff --git a/je_auto_control/gui/remote_desktop/webrtc_dialogs.py b/je_auto_control/gui/remote_desktop/webrtc_dialogs.py index 0e527b2d..68d3ff68 100644 --- a/je_auto_control/gui/remote_desktop/webrtc_dialogs.py +++ b/je_auto_control/gui/remote_desktop/webrtc_dialogs.py @@ -734,9 +734,13 @@ class LanBrowseDialog(QDialog): """ chosen = Signal(dict) + # Emitted from the zeroconf thread; a queued connection marshals the + # payload back onto the GUI thread (see _update_services). + _services_changed = Signal(dict) def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) + self._services_changed.connect(self._on_services_changed) self.setWindowTitle(_t("rd_webrtc_lan_title")) self.setMinimumSize(620, 260) self._services: dict = {} @@ -791,11 +795,14 @@ def _start_browser(self) -> None: self._browser = None def _update_services(self, services: dict) -> None: - # Called from zeroconf thread; marshal to GUI thread via signal-free - # workaround: invokeMethod is overkill here, just store + post. - self._services = dict(services) - from PySide6.QtCore import QTimer as _QTimer - _QTimer.singleShot(0, self._refresh) + # Called from the zeroconf browser thread, which has no Qt event loop: + # QTimer.singleShot would create a timer with that thread's affinity and + # never fire. Emit a signal instead — Qt queues it onto the GUI thread. + self._services_changed.emit(dict(services)) + + def _on_services_changed(self, services: dict) -> None: + self._services = services + self._refresh() def _refresh(self) -> None: items = sorted(self._services.values(), key=lambda s: s.get("host_id", "")) diff --git a/je_auto_control/gui/remote_desktop/webrtc_panel.py b/je_auto_control/gui/remote_desktop/webrtc_panel.py index 7b07cd4f..d5c226cd 100644 --- a/je_auto_control/gui/remote_desktop/webrtc_panel.py +++ b/je_auto_control/gui/remote_desktop/webrtc_panel.py @@ -110,6 +110,8 @@ class _PanelSignals(QObject): # Viewer-side file browser: list and op result. inbox_listing = Signal(object) # list[dict] inbox_op = Signal(str, bool, object) # name, ok, error + # Viewer-side: a file finished transferring (fired from the asyncio thread). + file_received = Signal(object) # path # Host-side: incoming viewer-shared screen frame viewer_video_frame = Signal(QImage) # Host-side: incoming annotation event from viewer @@ -1328,6 +1330,7 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._signals.stats.connect(self._on_stats) self._signals.inbox_listing.connect(self._on_inbox_listing) self._signals.inbox_op.connect(self._on_inbox_op_result) + self._signals.file_received.connect(self._on_file_received_ui) self._build_ui() self._refresh_address_book() self._update_availability() @@ -2386,11 +2389,14 @@ def _build_viewer(self, token: str) -> WebRTCDesktopViewer: return viewer def _on_received_file(self, path) -> None: - # Called from the asyncio thread; marshal to Qt via a status update. - QTimer.singleShot( - 0, lambda: self._status_label.setText( - _t("rd_webrtc_file_received").format(name=str(path)), - ), + # Called from the asyncio thread, which has no Qt event loop: + # QTimer.singleShot would never fire. Emit a signal — Qt queues the + # status update onto the GUI thread. + self._signals.file_received.emit(path) + + def _on_file_received_ui(self, path) -> None: + self._status_label.setText( + _t("rd_webrtc_file_received").format(name=str(path)), ) def _stop_viewer_if_any(self) -> None: diff --git a/je_auto_control/gui/script_builder/builder_tab.py b/je_auto_control/gui/script_builder/builder_tab.py index 904a7804..443f14da 100644 --- a/je_auto_control/gui/script_builder/builder_tab.py +++ b/je_auto_control/gui/script_builder/builder_tab.py @@ -21,6 +21,7 @@ from je_auto_control.gui.script_builder.step_model import ( Step, actions_to_steps, steps_to_actions, ) +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.executor.action_executor import execute_action from je_auto_control.utils.json.json_file import read_action_json, write_action_json @@ -129,7 +130,7 @@ def _on_save(self) -> None: actions = steps_to_actions(self._tree.root_steps()) write_action_json(path, actions) self._result.setPlainText(f"Saved: {path}") - except (OSError, ValueError, TypeError) as error: + except (AutoControlException, OSError, ValueError, TypeError) as error: QMessageBox.warning(self, "Error", str(error)) def _on_load(self) -> None: @@ -143,7 +144,7 @@ def _on_load(self) -> None: self._tree.load_steps(actions_to_steps(actions)) self._form.load_step(None) self._result.setPlainText(f"Loaded: {path}") - except (OSError, ValueError, TypeError) as error: + except (AutoControlException, OSError, ValueError, TypeError) as error: QMessageBox.warning(self, "Error", str(error)) def _on_run(self) -> None: @@ -156,5 +157,6 @@ def _on_run(self) -> None: self._result.setPlainText( json.dumps(result, indent=2, default=str, ensure_ascii=False) ) - except (OSError, ValueError, TypeError, RuntimeError) as error: + except (AutoControlException, OSError, ValueError, TypeError, + RuntimeError) as error: QMessageBox.warning(self, "Error", str(error)) diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index be1377da..283cc834 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -78,7 +78,6 @@ def _add_mouse_specs(specs: List[CommandSpec]) -> None: default="mouse_left"), FieldSpec("x", FieldType.INT, optional=True), FieldSpec("y", FieldType.INT, optional=True), - FieldSpec("times", FieldType.INT, optional=True, default=1, min_value=1), ), )) specs.append(CommandSpec( @@ -4840,7 +4839,9 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: )) specs.append(CommandSpec( "AC_execute_process", "Shell", "Start Executable", - fields=(FieldSpec("program_path", FieldType.FILE_PATH),), + # Must match start_exe(exe_path); the executor dispatches + # event(**params), so a renamed field is a guaranteed TypeError. + fields=(FieldSpec("exe_path", FieldType.FILE_PATH),), )) specs.append(CommandSpec( "AC_move_to_trash", "Shell", "Move File to Recycle Bin", diff --git a/je_auto_control/gui/script_builder/step_form_view.py b/je_auto_control/gui/script_builder/step_form_view.py index f336d4fd..014310bd 100644 --- a/je_auto_control/gui/script_builder/step_form_view.py +++ b/je_auto_control/gui/script_builder/step_form_view.py @@ -33,6 +33,14 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._step: Optional[Step] = None self._editors: Dict[str, QWidget] = {} + # 填表期間必須抑制 commit。編輯器在建立時就接上 textChanged, + # 而 _populate_from_step 是一格一格填的,每填一格都會觸發 + # _commit_field,讀到的是還沒填完的表單。 + # Suppresses commits while populating. Editors are wired to + # textChanged/toggled at build time, and _populate_from_step fills them + # one at a time — so without this each setText() commits a half-filled + # form, overwriting params from fields that have not been set yet. + self._populating = False self._layout = QFormLayout(self) self._title = QLabel(_t("sb_no_step_selected")) self._layout.addRow(self._title) @@ -60,7 +68,11 @@ def load_step(self, step: Optional[Step]) -> None: editor = self._build_editor(field_spec) self._editors[field_spec.name] = editor self._layout.addRow(self._field_label(field_spec), editor) - self._populate_from_step(spec, step) + self._populating = True + try: + self._populate_from_step(spec, step) + finally: + self._populating = False def _clear(self) -> None: while self._layout.rowCount() > 1: @@ -149,18 +161,33 @@ def _populate_from_step(self, spec: CommandSpec, step: Step) -> None: _set_editor_value(editor, field_spec, value) def _commit_field(self) -> None: - if self._step is None: + if self._step is None or self._populating: return spec = COMMAND_SPECS.get(self._step.command) if spec is None: return - new_params: Dict[str, Any] = {} + # 以現有參數為基礎,只更新這張表單管得到的欄位。 + # Start from the params the step already has and update only the fields + # this form owns. Rebuilding from scratch silently dropped anything the + # schema has no field for — and a hand-authored action legitimately + # carries such params (nested `actions` lists, structural dicts). The + # drop fired merely on selecting a step, and builder_tab then wrote the + # truncated version straight back over the user's file. + new_params: Dict[str, Any] = dict(self._step.params) for field_spec in spec.fields: editor = self._editors.get(field_spec.name) if editor is None: continue - value = _read_editor_value(editor, field_spec) + ok, value = _read_field_value(editor, field_spec) + if not ok: + # Transient unparseable input (mid-typing, or a loaded "100.0" + # in an int field): keep this field's existing param instead of + # aborting the whole commit, which would freeze every edit. + continue if value is None and field_spec.optional: + # An emptied optional field means "no value" — drop the key + # rather than leaving the previous one behind. + new_params.pop(field_spec.name, None) continue new_params[field_spec.name] = value self._step.params = new_params @@ -249,6 +276,18 @@ def _read_editor_value(editor: QWidget, spec: FieldSpec) -> Any: return reader(editor) if reader is not None else None +def _read_field_value(editor: QWidget, spec: FieldSpec) -> tuple[bool, Any]: + """Read one field, returning ``(ok, value)``. + + ``ok`` is ``False`` when the current text cannot be parsed (int/float/rgb + fields), so the caller can skip that field instead of aborting the commit. + """ + try: + return True, _read_editor_value(editor, spec) + except (ValueError, TypeError): + return False, None + + _EDITOR_BUILDERS = { FieldType.STRING: StepFormView._build_string, FieldType.INT: StepFormView._build_int, diff --git a/je_auto_control/gui/script_builder/step_list_view.py b/je_auto_control/gui/script_builder/step_list_view.py index 2289715d..93d24e59 100644 --- a/je_auto_control/gui/script_builder/step_list_view.py +++ b/je_auto_control/gui/script_builder/step_list_view.py @@ -25,7 +25,14 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self.setHeaderLabels(["Step"]) self.setColumnCount(1) - self.setDragDropMode(QTreeWidget.DragDropMode.InternalMove) + # Item drag-drop is deliberately disabled: Qt's InternalMove reorders + # the QTreeWidgetItems without touching the Step model (``_roots`` and + # each ``Step.bodies``), so Save/Run would silently emit the pre-drag + # script, and a drop onto a plain step left the tree in a shape that + # crashed ``remove_selected``. Reordering is done through + # ``move_selected`` (the Up/Down commands), which keeps model and view + # in lock-step. + self.setDragDropMode(QTreeWidget.DragDropMode.NoDragDrop) self.setSelectionMode(QTreeWidget.SelectionMode.SingleSelection) self._roots: List[Step] = [] self.itemSelectionChanged.connect(self._emit_selection) @@ -79,9 +86,13 @@ def remove_selected(self) -> None: self.takeTopLevelItem(index) return body_key = parent.data(0, ROLE_BODY_KEY) - grandparent_step: Step = parent.parent().data(0, ROLE_STEP) + grandparent = parent.parent() + if grandparent is None: + parent.removeChild(item) + return + grandparent_step: Step = grandparent.data(0, ROLE_STEP) siblings = grandparent_step.bodies.get(body_key, []) - if step in siblings: + if step in siblings: # identity match — Step uses eq=False siblings.remove(step) parent.removeChild(item) diff --git a/je_auto_control/gui/script_builder/step_model.py b/je_auto_control/gui/script_builder/step_model.py index 58590c37..f70c08cd 100644 --- a/je_auto_control/gui/script_builder/step_model.py +++ b/je_auto_control/gui/script_builder/step_model.py @@ -5,12 +5,17 @@ from je_auto_control.gui.script_builder.command_schema import COMMAND_SPECS -@dataclass +@dataclass(eq=False) class Step: """A single node in the script tree. ``bodies`` maps body key (``body``, ``then``, ``else``) to child steps, mirroring the flow-control structure in the executor. + + Equality is identity-based (``eq=False``): the tree keeps its Steps in + plain lists and locates the *selected* one with ``list.remove``/``index``/ + ``in``. Value equality would match the first structurally-equal Step, so + deleting or moving one of several duplicate steps corrupted the model. """ command: str params: Dict[str, Any] = field(default_factory=dict) @@ -36,14 +41,26 @@ def step_to_action(step: Step) -> list: def action_to_step(action: list) -> Step: - """Convert a single action entry back to a Step.""" - if not action or not isinstance(action[0], str): + """Convert a single action entry back to a Step. + + Raises ``ValueError`` for anything that is not a ``[command, params?]`` + list. Without the ``isinstance(action, list)`` guard a string entry was + silently mis-parsed (``"auto_control"`` became command ``"a"``) and a dict + entry raised a bare ``KeyError`` instead of a clear message. + """ + if not isinstance(action, list) or not action or not isinstance(action[0], str): raise ValueError(f"Invalid action: {action!r}") command = action[0] raw_params: Mapping[str, Any] = action[1] if len(action) > 1 and isinstance(action[1], dict) else {} spec = COMMAND_SPECS.get(command) body_keys: Tuple[str, ...] = spec.body_keys if spec else () + params, bodies = _split_params(raw_params, body_keys) + return Step(command=command, params=params, bodies=bodies) + +def _split_params(raw_params: Mapping[str, Any], body_keys: Tuple[str, ...] + ) -> Tuple[Dict[str, Any], Dict[str, List[Step]]]: + """Partition raw params into scalar params and nested body step-lists.""" params: Dict[str, Any] = {} bodies: Dict[str, List[Step]] = {} for key, value in raw_params.items(): @@ -51,12 +68,31 @@ def action_to_step(action: list) -> Step: bodies[key] = [action_to_step(child) for child in value] else: params[key] = value - return Step(command=command, params=params, bodies=bodies) + return params, bodies -def actions_to_steps(actions: list) -> List[Step]: - """Convert a flat action list to a list of Steps.""" - return [action_to_step(entry) for entry in actions] +def actions_to_steps(actions: Any) -> List[Step]: + """Convert an action list (or ``{"auto_control": [...]}`` wrapper) to Steps. + + Mirrors what the executor accepts. Any other shape raises ``ValueError`` + rather than fabricating placeholder Steps that a later Save would write + back over the user's file. + """ + return [action_to_step(entry) for entry in _unwrap_action_list(actions)] + + +def _unwrap_action_list(actions: Any) -> list: + """Return the bare action list, unwrapping the ``auto_control`` mapping.""" + if isinstance(actions, dict): + wrapped = actions.get("auto_control") + if wrapped is None: + raise ValueError("Action mapping has no 'auto_control' key") + actions = wrapped + if not isinstance(actions, list): + raise ValueError( + f"Expected an action list, got {type(actions).__name__}" + ) + return actions def steps_to_actions(steps: List[Step]) -> list: diff --git a/je_auto_control/gui/test_suite_tab.py b/je_auto_control/gui/test_suite_tab.py index 930a109d..53f23f9b 100644 --- a/je_auto_control/gui/test_suite_tab.py +++ b/je_auto_control/gui/test_suite_tab.py @@ -18,6 +18,7 @@ language_wrapper, ) import je_auto_control as ac +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.quarantine import ( auto_quarantine_from_flakiness, default_quarantine_store, ) @@ -85,14 +86,22 @@ def _parse_spec(self): def _on_load_file(self) -> None: path, _ = QFileDialog.getOpenFileName(self, _t("suite_load_file")) - if path: + if not path: + return + try: with open(path, encoding="utf-8") as handle: self._spec.setPlainText(handle.read()) + except (OSError, ValueError) as err: + # ValueError covers UnicodeDecodeError on a non-UTF-8 file; OSError + # covers unreadable/permission errors. Surface instead of escaping + # the Actions slot into the Qt event loop. + self._summary.setText(_t("suite_error").replace("{error}", str(err))) def _on_run(self) -> None: try: result = ac.run_suite(self._parse_spec()) - except (ValueError, OSError, RuntimeError) as err: + except (AutoControlException, ValueError, TypeError, + OSError, RuntimeError) as err: self._summary.setText(_t("suite_error").replace("{error}", str(err))) return self._last_result = result diff --git a/je_auto_control/linux_wayland/listener.py b/je_auto_control/linux_wayland/listener.py index 78be4a15..1c44dfa1 100644 --- a/je_auto_control/linux_wayland/listener.py +++ b/je_auto_control/linux_wayland/listener.py @@ -28,4 +28,21 @@ def hook_keyboard(*_args, **_kwargs): ) -__all__ = ["check_key_press", "hook_keyboard"] +def check_key_is_press(keycode: int | None = None) -> bool: + """Best-effort key-state query; on Wayland this always reports ``False``. + + Every other backend exposes ``check_key_is_press`` and the wrapper's + critical-exit watcher calls it on a timer. Wayland cannot read the + global key state from an unprivileged client, so rather than omitting + the name (which would ``AttributeError`` and kill the critical-exit + thread) this reports ``False`` — the panic key is inert on Wayland, + but callers degrade gracefully instead of crashing. + + :param keycode: key to query; ignored because no query is possible. + :return: always ``False``. + """ + del keycode + return False + + +__all__ = ["check_key_is_press", "check_key_press", "hook_keyboard"] diff --git a/je_auto_control/linux_wayland/mouse.py b/je_auto_control/linux_wayland/mouse.py index c271c3fe..0d94e617 100644 --- a/je_auto_control/linux_wayland/mouse.py +++ b/je_auto_control/linux_wayland/mouse.py @@ -16,7 +16,14 @@ from je_auto_control.utils.exception.exceptions import AutoControlException -# ydotool ``click`` accepts hex bitmasks. Hold-bit (0x40) | press (0x00). +# ydotool ``click`` accepts hex bitmasks: the low nibble selects the +# button (0 left, 1 right, 2 middle) and the high bits toggle the edge — +# ``0x40`` press-down, ``0x80`` release-up. The constants below carry both +# edges set (a full down+up click); ``_press_code`` / ``_release_code`` +# isolate a single edge so a press+move+release drag actually holds. +_YDOTOOL_DOWN_BIT = 0x40 +_YDOTOOL_UP_BIT = 0x80 + wayland_mouse_left = 0xC0 wayland_mouse_middle = 0xC2 wayland_mouse_right = 0xC1 @@ -92,16 +99,26 @@ def _try_libei(): return None +def _press_code(mouse_keycode: int) -> int: + """Button-down only: set the down-bit, clear the up-bit.""" + return (int(mouse_keycode) | _YDOTOOL_DOWN_BIT) & ~_YDOTOOL_UP_BIT + + +def _release_code(mouse_keycode: int) -> int: + """Button-up only: set the up-bit, clear the down-bit.""" + return (int(mouse_keycode) | _YDOTOOL_UP_BIT) & ~_YDOTOOL_DOWN_BIT + + def press_mouse(mouse_keycode: int) -> None: - """Press a mouse button (hold).""" + """Press a mouse button and hold it (down edge only).""" time.sleep(0.01) - _run([_require_ydotool(), "click", f"{int(mouse_keycode) | 0x40:#x}"]) + _run([_require_ydotool(), "click", f"{_press_code(mouse_keycode):#x}"]) def release_mouse(mouse_keycode: int) -> None: - """Release a held mouse button.""" + """Release a held mouse button (up edge only).""" time.sleep(0.01) - _run([_require_ydotool(), "click", f"{int(mouse_keycode) & ~0x40:#x}"]) + _run([_require_ydotool(), "click", f"{_release_code(mouse_keycode):#x}"]) def click_mouse(mouse_keycode: int, x: Optional[int] = None, @@ -113,13 +130,23 @@ def click_mouse(mouse_keycode: int, x: Optional[int] = None, _run([_require_ydotool(), "click", f"{int(mouse_keycode):#x}"]) -def scroll(direction: int, x: Optional[int] = None, - y: Optional[int] = None) -> None: - """Scroll by ``direction`` notches (positive = up, negative = down).""" - if x is not None and y is not None: - set_position(int(x), int(y)) - _run([_require_ydotool(), "mousemove", "--wheel", - "-y", str(int(direction))]) +def scroll(scroll_value: int, + scroll_direction: int = wayland_scroll_direction_down) -> None: + """Scroll ``scroll_value`` notches in ``scroll_direction``. + + The signature mirrors the x11 backend's ``scroll(scroll_value, + scroll_direction)`` because the wrapper treats every Linux backend + uniformly — it cannot tell Wayland from X11 by ``sys.platform``. The + previous ``(direction, x, y)`` shape silently bound the wrapper's + direction to ``x`` and then dropped it, so every scroll went the same way. + + ``scroll_direction`` carries axis and sign, per this module's + ``wayland_scroll_direction_*`` constants: +-1 vertical, +-2 horizontal. + """ + direction = int(scroll_direction) + axis = "-y" if abs(direction) == 1 else "-x" + amount = abs(int(scroll_value)) * (1 if direction > 0 else -1) + _run([_require_ydotool(), "mousemove", "--wheel", axis, str(amount)]) def send_mouse_event_to_window(*_args, **_kwargs) -> None: diff --git a/je_auto_control/linux_wayland/screen.py b/je_auto_control/linux_wayland/screen.py index 623e39ba..4ae4dc18 100644 --- a/je_auto_control/linux_wayland/screen.py +++ b/je_auto_control/linux_wayland/screen.py @@ -55,9 +55,12 @@ def _run(argv: list, *, timeout: float = 10.0) -> bytes: return completed.stdout or b"" -def screen_size() -> Tuple[int, int]: +def size() -> Tuple[int, int]: """Return the primary monitor's pixel size. + Named ``size`` to match the backend contract the wrapper calls + (``screen.size()``), as the windows / osx / x11 backends all do. + Tries ``wlr-randr`` first (sway / hyprland) then falls back to grim's PNG header so the call still works on GNOME / KDE without extra dependencies. @@ -68,6 +71,22 @@ def screen_size() -> Tuple[int, int]: return _size_from_grim_capture() +def get_pixel(x: int, y: int) -> Tuple[int, int, int]: + """Return the ``(r, g, b)`` colour at ``(x, y)``. + + grim can capture an arbitrary region, so a 1x1 grab at the requested + point is the cheapest way to read a single pixel. Returns RGB to match + the x11 backend. + """ + grim = _require_grim() + data = _run([grim, "-g", f"{int(x)},{int(y)} 1x1", "-"], timeout=10.0) + if not data: + raise AutoControlException("grim produced no output") + from io import BytesIO + with Image.open(BytesIO(data)) as image: + return image.convert("RGB").getpixel((0, 0)) + + def screenshot(file_path: Optional[str] = None, screen_region: Optional[List[int]] = None) -> Optional[str]: """Capture the screen with ``grim``. @@ -113,4 +132,4 @@ def _size_from_grim_capture() -> Tuple[int, int]: return int(image.width), int(image.height) -__all__ = ["screen_size", "screenshot"] +__all__ = ["size", "get_pixel", "screenshot"] diff --git a/je_auto_control/linux_with_x11/mouse/x11_linux_mouse_control.py b/je_auto_control/linux_with_x11/mouse/x11_linux_mouse_control.py index e05592a3..dff94b70 100644 --- a/je_auto_control/linux_with_x11/mouse/x11_linux_mouse_control.py +++ b/je_auto_control/linux_with_x11/mouse/x11_linux_mouse_control.py @@ -91,13 +91,17 @@ def scroll(scroll_value: int, scroll_direction: int) -> None: 模擬滑鼠滾動 :param scroll_value: number of scroll units 滾動次數 + 方向由 scroll_direction 決定,因此這裡只取絕對值。 + Direction comes from scroll_direction, so only the magnitude is used + here: range() on a negative value is empty, which silently scrolled + nothing at all. :param scroll_direction: scroll direction 滾動方向 4 = up 上 5 = down 下 6 = left 左 7 = right 右 """ - for _ in range(scroll_value): + for _ in range(abs(int(scroll_value))): click_mouse(scroll_direction) diff --git a/je_auto_control/linux_with_x11/screen/x11_linux_screen.py b/je_auto_control/linux_with_x11/screen/x11_linux_screen.py index 0d4560e0..d509fbdf 100644 --- a/je_auto_control/linux_with_x11/screen/x11_linux_screen.py +++ b/je_auto_control/linux_with_x11/screen/x11_linux_screen.py @@ -13,6 +13,22 @@ from je_auto_control.linux_with_x11.core.utils.x11_linux_display import display +def _decode_pixel(data: bytes) -> Tuple[int, int, int]: + """Decode a little-endian TrueColor pixel buffer into ``(R, G, B)``. + + ``root.get_image`` returns the pixel in the server's native byte + order; on the dominant little-endian TrueColor visual the bytes are + laid out blue, green, red(, unused), so the first three must be + reversed to yield true RGB — matching the windows / osx backends, + which all return ``(R, G, B)``. + + :param data: raw ZPixmap bytes (at least three: B, G, R) + :return: (R, G, B) + """ + blue, green, red = data[0], data[1], data[2] + return red, green, blue + + def size() -> Tuple[int, int]: """ Get screen size @@ -23,11 +39,15 @@ def size() -> Tuple[int, int]: return display.screen().width_in_pixels, display.screen().height_in_pixels -def get_pixel_rgb(x: int, y: int) -> Tuple[int, int, int]: +def get_pixel(x: int, y: int) -> Tuple[int, int, int]: """ Get RGB value of pixel at given coordinates 取得指定座標的像素 RGB 值 + 名稱需與 wrapper 呼叫的 ``screen.get_pixel`` 一致。 + Named to match the backend contract the wrapper calls + (``screen.get_pixel``), as the windows / osx backends do. + :param x: X coordinate X 座標 :param y: Y coordinate Y 座標 :return: (R, G, B) 三原色值 @@ -38,6 +58,5 @@ def get_pixel_rgb(x: int, y: int) -> Tuple[int, int, int]: # 取得影像資料 Get image data raw = root.get_image(x, y, 1, 1, X.ZPixmap, 0xffffffff) - # raw.data 是 bytes,需要轉換成 RGB - pixel = tuple(raw.data[:3]) # (R, G, B) - return pixel \ No newline at end of file + # raw.data 是 little-endian BGR(X) 位元組,需轉成 RGB + return _decode_pixel(raw.data) \ No newline at end of file diff --git a/je_auto_control/osx/mouse/osx_mouse.py b/je_auto_control/osx/mouse/osx_mouse.py index a7aaf28e..a2ce61a4 100644 --- a/je_auto_control/osx/mouse/osx_mouse.py +++ b/je_auto_control/osx/mouse/osx_mouse.py @@ -24,10 +24,26 @@ def position() -> Tuple[int, int]: Get current mouse position 取得目前滑鼠座標位置 - :return: (x, y) 滑鼠座標 + NSEvent.mouseLocation() 的原點在左下角,但本模組送出的 CGEvent 事件 + 以左上角為原點,因此必須翻轉 y。未翻轉時,未指定座標的點擊會落在 + 垂直鏡像的位置(只有游標剛好在畫面正中央時才正確)。 + NSEvent.mouseLocation() has a bottom-left origin, while the CGEvents this + module posts use a top-left origin — as do the windows and x11 backends. + Without flipping y, a coordinate read here and fed back into press/click + (which is exactly what mouse_preprocess does when x/y are omitted) lands + at the vertically mirrored point. + + :return: (x, y) 滑鼠座標,原點為左上角 top-left origin """ loc = Quartz.NSEvent.mouseLocation() - return int(loc.x), int(loc.y) + # 用點(point)為單位的顯示高度翻轉 y。CGDisplayPixelsHigh 回傳的是像素 + # 高度,在 Retina/HiDPI 螢幕上是點高度的 2 倍,會讓翻轉後的 y 落在錯誤位置。 + # mouseLocation() 與這裡送出的 CGEvent 都以點為單位。 + # Flip y using the point-based display height. CGDisplayPixelsHigh returns + # pixels (2x the point height on Retina/HiDPI), which would offset the + # flipped y; mouseLocation() and the posted CGEvents are both in points. + height = Quartz.CGDisplayBounds(Quartz.CGMainDisplayID()).size.height + return int(loc.x), int(height - loc.y) def mouse_event(event: int, x: int, y: int, mouse_button: int) -> None: diff --git a/je_auto_control/osx/screen/osx_screen.py b/je_auto_control/osx/screen/osx_screen.py index bb966cef..a687388c 100644 --- a/je_auto_control/osx/screen/osx_screen.py +++ b/je_auto_control/osx/screen/osx_screen.py @@ -14,6 +14,26 @@ import Quartz +class CGPoint(ctypes.Structure): + """CoreGraphics CGPoint (two doubles).""" + _fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)] + + +class CGSize(ctypes.Structure): + """CoreGraphics CGSize (two doubles).""" + _fields_ = [("width", ctypes.c_double), ("height", ctypes.c_double)] + + +class CGRect(ctypes.Structure): + """CoreGraphics CGRect. + + Must be a real Structure, not ``c_double * 4``: ctypes passes an array + argument as a *pointer*, whereas CGWindowListCreateImage takes its CGRect + **by value**. The array form made the capture rect garbage. + """ + _fields_ = [("origin", CGPoint), ("size", CGSize)] + + def size() -> Tuple[int, int]: """ Get screen size @@ -27,24 +47,27 @@ def size() -> Tuple[int, int]: ) -def get_pixel(x: int, y: int) -> Tuple[int, int, int, int]: +def get_pixel(x: int, y: int) -> Tuple[int, int, int]: """ - Get RGBA value of pixel at given coordinates - 取得指定座標的像素 RGBA 值 + Get RGB value of pixel at given coordinates + 取得指定座標的像素 RGB 值 + + 回傳 RGB 以與 windows / x11 / wayland 後端一致:先前回傳 RGBA, + 使得 ``r, g, b = get_pixel(...)`` 只在 macOS 上失敗。 + Returns RGB to match the windows / x11 / wayland backends. This previously + returned RGBA, so ``r, g, b = get_pixel(...)`` unpacked fine everywhere + except macOS. :param x: X coordinate X 座標 :param y: Y coordinate Y 座標 - :return: (R, G, B, A) 四原色值 + :return: (R, G, B) 三原色值 """ # 載入 CoreGraphics 與 CoreFoundation 函式庫 cg = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation") - # 定義 CGRect 結構 (x, y, width, height) - cg_rect_t = ctypes.c_double * 4 - # 設定函式簽名 Function signatures - cg.CGWindowListCreateImage.argtypes = [cg_rect_t, c_uint32, c_uint32, c_uint32] + cg.CGWindowListCreateImage.argtypes = [CGRect, c_uint32, c_uint32, c_uint32] cg.CGWindowListCreateImage.restype = c_void_p cg.CGImageGetDataProvider.argtypes = [c_void_p] @@ -69,7 +92,7 @@ def get_pixel(x: int, y: int) -> Tuple[int, int, int, int]: window_image_default = 0 # 建立擷取範圍 Create capture rect - rect = cg_rect_t(x, y, 1.0, 1.0) + rect = CGRect(CGPoint(float(x), float(y)), CGSize(1.0, 1.0)) # 擷取螢幕影像 Capture screen image img = cg.CGWindowListCreateImage( @@ -83,10 +106,14 @@ def get_pixel(x: int, y: int) -> Tuple[int, int, int, int]: "Unable to capture screen image. 請確認已授予螢幕錄製權限" ) - provider = None cfdata = None try: # 取得影像資料供應器 Get data provider + # CGImageGetDataProvider 是 "Get" 函式,呼叫端不擁有這個參考, + # 因此絕對不可以 CFRelease,否則會過度釋放。 + # Per the CoreFoundation Get Rule, a "Get" function does not transfer + # ownership: releasing `provider` here underflowed its refcount, and + # releasing `img` below (which owns the provider) then freed it again. provider = cg.CGImageGetDataProvider(img) # 複製影像資料 Copy image data @@ -100,14 +127,17 @@ def get_pixel(x: int, y: int) -> Tuple[int, int, int, int]: # 取得 byte pointer Get byte pointer buf = cf.CFDataGetBytePtr(cfdata) - # 預設像素格式為 BGRA Default pixel format is BGRA - b, g, r, a = buf[0], buf[1], buf[2], buf[3] + # 預設像素格式為 BGRA;捨棄 alpha 以符合其他後端的 RGB 回傳。 + # Pixel format is BGRA; drop alpha so the return matches the other + # backends' RGB. + b, g, r = buf[0], buf[1], buf[2] - return r, g, b, a + return r, g, b finally: - # 釋放 CoreFoundation 物件 Release CF objects + # 只釋放自己擁有的物件:cfdata 來自 "Copy"、img 來自 "Create"。 + # provider 來自 "Get",不屬於我們,不能釋放。 + # Release only what we own: cfdata came from a Copy function and img + # from a Create function. provider came from a Get function. if cfdata: cf.CFRelease(cfdata) - if provider: - cf.CFRelease(provider) cf.CFRelease(img) \ No newline at end of file diff --git a/je_auto_control/utils/acme_v2/client.py b/je_auto_control/utils/acme_v2/client.py index 2cc96469..3809fcdf 100644 --- a/je_auto_control/utils/acme_v2/client.py +++ b/je_auto_control/utils/acme_v2/client.py @@ -20,6 +20,7 @@ _USER_AGENT = "autocontrol-acme/1.0" _JOSE_CONTENT_TYPE = "application/jose+json" +_BAD_NONCE_ERROR = "urn:ietf:params:acme:error:badNonce" class AcmeError(RuntimeError): @@ -266,6 +267,22 @@ def _signed_post(self, url: str, *, use_jwk: bool = False, accept: Optional[str] = None, ) -> tuple: + status, parsed, headers = self._post_once( + url, payload, use_jwk=use_jwk, accept=accept, + ) + if _is_bad_nonce(status, parsed): + # RFC 8555 §6.5: a badNonce rejection is retryable. The error + # response carried a fresh Replay-Nonce (already cached by + # _post_once), so retry exactly once with it. + status, parsed, headers = self._post_once( + url, payload, use_jwk=use_jwk, accept=accept, + ) + return status, parsed, headers + + def _post_once(self, url: str, + payload: Optional[Mapping[str, Any]], + *, use_jwk: bool, accept: Optional[str]) -> tuple: + """Sign and POST once; cache the server's next Replay-Nonce.""" nonce = self._nonce or self._fresh_nonce() self._nonce = None try: @@ -327,6 +344,12 @@ def _http(self, method: str, url: str, *, return status, body_value, resp_headers +def _is_bad_nonce(status: int, parsed: Any) -> bool: + """Whether an ACME response is a retryable badNonce error (RFC 8555 §6.5).""" + return (status >= 400 and isinstance(parsed, Mapping) + and parsed.get("type") == _BAD_NONCE_ERROR) + + def _build_authorization(url: str, body: Mapping[str, Any] ) -> AcmeAuthorization: challenges = [ diff --git a/je_auto_control/utils/admin/admin_client.py b/je_auto_control/utils/admin/admin_client.py index c3fa3973..493c3fc5 100644 --- a/je_auto_control/utils/admin/admin_client.py +++ b/je_auto_control/utils/admin/admin_client.py @@ -11,6 +11,7 @@ import json import os +import tempfile import threading import time import urllib.request @@ -234,27 +235,71 @@ def _load(self) -> None: autocontrol_logger.warning("admin: load %s failed: %r", self._path, error) return + # 一個損毀的檔案必須退化成空簿,而不是讓 __init__ 崩潰。原本只有 + # json.loads 在 try 內:非物件的頂層 JSON(如 [] / null)會讓 + # payload.get 拋 AttributeError,而多/缺鍵的 entry 會讓 + # AdminHost(**entry) 拋 TypeError,兩者都逸出建構子;而 + # default_admin_console() 只在成功時快取,所以會每次重拋。 + # A corrupt file must degrade to an empty book, not crash __init__. + # Only json.loads was inside the try: a non-object top-level JSON + # (e.g. [] or null) makes payload.get raise AttributeError, and an + # entry with extra/missing keys makes AdminHost(**entry) raise + # TypeError — both escaped the constructor, and default_admin_console() + # caches only on success, so it re-raised on every later call. + if not isinstance(payload, dict): + autocontrol_logger.warning( + "admin: %s is not a JSON object; ignoring", self._path) + return + hosts: Dict[str, AdminHost] = {} + for entry in payload.get("hosts", []): + if not (isinstance(entry, dict) and entry.get("label")): + continue + try: + hosts[entry["label"]] = AdminHost(**entry) + except TypeError as error: + # Skip a malformed entry (extra/missing field) but keep the + # rest of the book usable. + autocontrol_logger.warning( + "admin: skipping malformed host %r in %s: %r", + entry.get("label"), self._path, error) with self._lock: - self._hosts = { - entry["label"]: AdminHost(**entry) - for entry in payload.get("hosts", []) - if isinstance(entry, dict) and entry.get("label") - } + self._hosts = hosts def _save(self) -> None: + # Snapshot and write under the same lock. Splitting them let a stale + # snapshot from one add_host overwrite a newer write from a concurrent + # add_host, silently losing a host. The write itself is atomic (temp + + # os.replace) so a crash mid-write can never truncate the token file. with self._lock: payload = {"hosts": [asdict(h) for h in self._hosts.values()]} + try: + self._write_atomic(payload) + except OSError as error: + autocontrol_logger.warning("admin: save %s failed: %r", + self._path, error) + + def _write_atomic(self, payload: Dict[str, Any]) -> None: + """Write ``payload`` as JSON to ``self._path`` atomically and 0o600.""" + self._path.parent.mkdir(parents=True, exist_ok=True) + data = json.dumps(payload, indent=2, ensure_ascii=False) + # mkstemp creates the file 0o600 on POSIX, so the tokens are never + # briefly world-readable in the gap before os.replace. + fd, tmp_name = tempfile.mkstemp( + prefix=".admin_hosts_", suffix=".tmp", + dir=str(self._path.parent), + ) try: - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.write_text( - json.dumps(payload, indent=2, ensure_ascii=False), - encoding="utf-8", - ) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(data) + os.replace(tmp_name, self._path) if os.name == "posix": os.chmod(self._path, 0o600) - except OSError as error: - autocontrol_logger.warning("admin: save %s failed: %r", - self._path, error) + except OSError: + try: + os.unlink(tmp_name) + except OSError: + pass + raise _default_console: Optional[AdminConsoleClient] = None diff --git a/je_auto_control/utils/agent/agent_loop.py b/je_auto_control/utils/agent/agent_loop.py index 6fe7ebb1..a96935e1 100644 --- a/je_auto_control/utils/agent/agent_loop.py +++ b/je_auto_control/utils/agent/agent_loop.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Sequence +from je_auto_control.utils.exception.exceptions import AutoControlException + @dataclass class AgentBudget: @@ -158,7 +160,12 @@ def _dispatch_tool(self, index: int, tool: str, ): try: step.result = self._tool_runner(tool, args) - except (ValueError, RuntimeError, OSError) as error: + # A failing AC_* tool raises AutoControl*Exception (all subclass + # AutoControlException), and a hallucinated kwarg raises TypeError. + # Record the error per-step so the loop keeps going instead of + # crashing the whole run. + except (AutoControlException, TypeError, + ValueError, RuntimeError, OSError) as error: step.error = f"{type(error).__name__}: {error}" return step diff --git a/je_auto_control/utils/agent/backends/anthropic.py b/je_auto_control/utils/agent/backends/anthropic.py index a5590823..670eabdd 100644 --- a/je_auto_control/utils/agent/backends/anthropic.py +++ b/je_auto_control/utils/agent/backends/anthropic.py @@ -71,6 +71,21 @@ def decide_next_action(self, goal: str, tools=self._tools, messages=self._conversation, max_tokens=self._max_tokens, + # 這個迴圈一次只執行一個工具:_handle_response 只回傳第一個 + # tool_use,_ingest_history 也只附上一筆 tool_result。平行 + # 工具呼叫預設是開啟的,一旦模型回傳兩個 tool_use,下一次 + # 請求就會因為有 tool_use 沒有對應的 tool_result 而 400。 + # This loop executes exactly one tool per step: _handle_response + # returns only the first tool_use block and _ingest_history + # appends exactly one tool_result. Parallel tool use is on by + # default, and every tool_use must be answered — so a response + # with two tool_use blocks made the *next* create() 400 with an + # unanswered tool_use id, killing the run. Disabling it matches + # the API to the loop this backend actually implements. + tool_choice={ + "type": "auto", + "disable_parallel_tool_use": True, + }, ) except Exception as exc: # noqa: BLE001 rewrap to a clear backend error raise AgentBackendError( @@ -95,7 +110,11 @@ def _handle_response(self, response: Any) -> Dict[str, Any]: "input": _attr(block, "input") or {}, "_tool_use_id": _attr(block, "id"), } - # No tool_use — interpret the text as a final answer + stop. + # No tool_use — interpret the text as a final answer + stop, unless + # the turn was cut short (default max_tokens can be hit mid-plan, or + # the model may refuse). Surfacing a truncated reply as a successful + # final answer would silently end the run with a half-formed message. + _raise_if_truncated(response) text_parts: List[str] = [] for block in content: block_type = ( @@ -159,6 +178,18 @@ def _attr(block: Any, name: str) -> Any: return getattr(block, name, None) +_TRUNCATION_STOP_REASONS = frozenset({"max_tokens", "refusal"}) + + +def _raise_if_truncated(response: Any) -> None: + """Reject an incomplete turn instead of treating it as a final answer.""" + stop_reason = _attr(response, "stop_reason") + if stop_reason in _TRUNCATION_STOP_REASONS: + raise AgentBackendError( + f"anthropic response was incomplete (stop_reason={stop_reason!r})", + ) + + def _last_tool_use_id(conversation: Sequence[Dict[str, Any]]) -> Optional[str]: """Walk the conversation backwards for the most recent tool_use id.""" for msg in reversed(conversation): diff --git a/je_auto_control/utils/agent/backends/anthropic_computer_use.py b/je_auto_control/utils/agent/backends/anthropic_computer_use.py index f57599dc..11691726 100644 --- a/je_auto_control/utils/agent/backends/anthropic_computer_use.py +++ b/je_auto_control/utils/agent/backends/anthropic_computer_use.py @@ -140,6 +140,15 @@ def decide_next_action(self, tools=[self._tool_schema], messages=self._conversation, max_tokens=self._max_tokens, + # This loop answers exactly one tool_use per turn, so parallel + # tool use must stay off: a response with two computer tool_use + # blocks would leave the second unanswered and the next + # create() would 400 on the dangling tool_use id, aborting the + # run. Mirror AnthropicAgentBackend's fix. + tool_choice={ + "type": "auto", + "disable_parallel_tool_use": True, + }, ) except Exception as exc: # noqa: BLE001 rewrap to backend error raise AgentBackendError( @@ -161,7 +170,10 @@ def _handle_response(self, response: Any) -> Dict[str, Any]: payload = _attr(block, "input") or {} self._pending_tool_use_id = _attr(block, "id") return _decision_from_computer_action(payload) - # No tool_use → final answer + stop. + # No tool_use → final answer + stop, unless the turn was cut short + # (default max_tokens can be hit mid-plan, or the model may refuse): + # a truncated reply must not be reported as a successful final answer. + _raise_if_truncated(response) text_parts: List[str] = [ _attr(b, "text") or "" for b in content if _block_type(b) == "text" @@ -221,10 +233,10 @@ def _action_type(payload): def _action_wait(payload): - return { - "tool": "AC_sleep", - "input": {"seconds": float(payload.get("duration") or 1.0)}, - } + duration = payload.get("duration") + # An explicit 0 is a valid "don't wait" — only fall back when unset. + seconds = float(duration) if duration is not None else 1.0 + return {"tool": "AC_sleep", "input": {"seconds": seconds}} _CLICK_ACTIONS = frozenset({ @@ -277,7 +289,9 @@ def _drag_decision(payload: Dict[str, Any]) -> Dict[str, Any]: def _scroll_decision(payload: Dict[str, Any]) -> Dict[str, Any]: direction = str(payload.get("scroll_direction") or "down").lower() - amount = int(payload.get("scroll_amount") or 3) + raw_amount = payload.get("scroll_amount") + # An explicit 0 means "no scroll" — only default when the key is absent. + amount = int(raw_amount) if raw_amount is not None else 3 delta = amount if direction == "up" else -amount return {"tool": "AC_mouse_scroll", "input": {"scroll_value": delta}} @@ -303,6 +317,23 @@ def _hold_key_decision(payload: Dict[str, Any]) -> Dict[str, Any]: } +def _mouse_button_decision(tool: str, payload: Dict[str, Any]) -> Dict[str, Any]: + """Map a bare press/release verb to an ``AC_press/release_mouse`` call.""" + inputs: Dict[str, Any] = {"mouse_keycode": "mouse_left"} + coordinate = payload.get("coordinate") + if coordinate is not None: + inputs["x"], inputs["y"] = _xy(coordinate) + return {"tool": tool, "input": inputs} + + +def _mouse_down_decision(payload: Dict[str, Any]) -> Dict[str, Any]: + return _mouse_button_decision("AC_press_mouse", payload) + + +def _mouse_up_decision(payload: Dict[str, Any]) -> Dict[str, Any]: + return _mouse_button_decision("AC_release_mouse", payload) + + # Dispatch table — populated after every handler is defined so the # table reads its targets at module load time. _ACTION_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = { @@ -315,9 +346,27 @@ def _hold_key_decision(payload: Dict[str, Any]) -> Dict[str, Any]: "scroll": _scroll_decision, "key": _key_decision, "hold_key": _hold_key_decision, + # computer_20250124 press/release verbs — without these an otherwise + # valid action raised AgentBackendError outside the create() try and + # aborted the run. + "left_mouse_down": _mouse_down_decision, + "left_mouse_up": _mouse_up_decision, } +_TRUNCATION_STOP_REASONS = frozenset({"max_tokens", "refusal"}) + + +def _raise_if_truncated(response: Any) -> None: + """Reject an incomplete turn instead of treating it as a final answer.""" + stop_reason = _attr(response, "stop_reason") + if stop_reason in _TRUNCATION_STOP_REASONS: + raise AgentBackendError( + "anthropic computer-use response was incomplete " + f"(stop_reason={stop_reason!r})", + ) + + # --- helpers -------------------------------------------------------- def _xy(coordinate: Any) -> Tuple[int, int]: diff --git a/je_auto_control/utils/agent/backends/openai.py b/je_auto_control/utils/agent/backends/openai.py index a9c80755..0dcad0b4 100644 --- a/je_auto_control/utils/agent/backends/openai.py +++ b/je_auto_control/utils/agent/backends/openai.py @@ -68,6 +68,11 @@ def decide_next_action(self, goal: str, messages=self._messages, tools=self._tools, tool_choice="auto", + # This loop executes and answers only tool_calls[0] each turn, + # yet the whole assistant message (with every tool_call) is + # replayed. Parallel calls would leave the rest unanswered and + # the next request would 400, ending the run. + parallel_tool_calls=False, ) except Exception as exc: # noqa: BLE001 rewrap to a clear backend error raise AgentBackendError( @@ -114,11 +119,22 @@ def _handle_response(self, response: Any) -> Dict[str, Any]: args = {} self._pending_tool_call_id = call.id return {"tool": fn.name, "input": args} - # No tool call → final answer. + # No tool call → final answer, unless the turn was truncated at the + # token cap: returning a length-cut reply as the final answer would + # silently end the run mid-plan. + _raise_if_truncated(choice) text = getattr(message, "content", None) or "" return {"stop": True, "message": text.strip() if isinstance(text, str) else ""} +def _raise_if_truncated(choice: Any) -> None: + """Reject a length-truncated turn instead of returning it as final.""" + if getattr(choice, "finish_reason", None) == "length": + raise AgentBackendError( + "openai response truncated (finish_reason='length')", + ) + + def _build_user_content(screenshot: Optional[bytes]) -> List[Dict[str, Any]]: blocks: List[Dict[str, Any]] = [] encoded = encode_screenshot_b64(screenshot) diff --git a/je_auto_control/utils/anchor_locator/locator.py b/je_auto_control/utils/anchor_locator/locator.py index feb156f8..79afdf32 100644 --- a/je_auto_control/utils/anchor_locator/locator.py +++ b/je_auto_control/utils/anchor_locator/locator.py @@ -17,6 +17,16 @@ from dataclasses import asdict, dataclass from typing import Any, Dict, List, Optional, Tuple +from je_auto_control.utils.exception.exceptions import AutoControlException + + +# Errors a backend adapter treats as "simply not on screen" and turns into an +# empty result. ``AutoControlException`` covers ``ImageNotFoundException`` (from +# locate_image_center / locate_all_image) and ``AutoControlActionException`` +# (from locate_text_center) — without it a plain not-found would crash the +# whole anchor locate instead of returning found=False. +_LOCATE_ERRORS = (OSError, RuntimeError, ValueError, AutoControlException) + # Spatial relations the wrapper understands. REL_ABOVE = "above" @@ -262,6 +272,10 @@ def _ranked(anchor_point: Tuple[int, int], if not _matches_relation(anchor_point, bbox, relation): continue distance = _euclid(anchor_point, bbox.center) + # max_distance is a proximity radius for REL_NEAR only; directional + # relations ("the 3rd row below the header") intentionally return + # matches at any distance, sorted by distance, so ordinal selection and + # long-table row picking keep working past the default radius. if relation == REL_NEAR and distance > max_distance: continue matches.append((bbox.center, distance)) @@ -305,7 +319,7 @@ def _image_center(locator: Locator) -> Optional[Tuple[int, int]]: locator.template_path, detect_threshold=locator.detect_threshold, ) - except (OSError, RuntimeError, ValueError): + except _LOCATE_ERRORS: return None @@ -318,7 +332,7 @@ def _image_candidates(locator: Locator) -> List[_Bbox]: locator.template_path, detect_threshold=locator.detect_threshold, ) - except (OSError, RuntimeError, ValueError): + except _LOCATE_ERRORS: return [] return [_Bbox(*map(int, row[:4])) for row in rows if isinstance(row, (list, tuple)) and len(row) >= 4] @@ -332,7 +346,7 @@ def _ocr_center(locator: Locator) -> Optional[Tuple[int, int]]: region=list(locator.region) if locator.region else None, min_confidence=locator.min_confidence, ) - except (OSError, RuntimeError, ValueError): + except _LOCATE_ERRORS: return None @@ -344,7 +358,7 @@ def _ocr_candidates(locator: Locator) -> List[_Bbox]: region=list(locator.region) if locator.region else None, min_confidence=locator.min_confidence, ) - except (OSError, RuntimeError, ValueError): + except _LOCATE_ERRORS: return [] return [_Bbox(x1=m.x, y1=m.y, x2=m.x + m.width, y2=m.y + m.height) for m in matches] @@ -356,7 +370,7 @@ def _vlm_point(locator: Locator) -> Optional[Tuple[int, int]]: return locate_by_description( locator.description, model=locator.model, ) - except (OSError, RuntimeError, ValueError): + except _LOCATE_ERRORS: return None @@ -368,7 +382,7 @@ def _a11y_point(locator: Locator) -> Optional[Tuple[int, int]]: element = find_accessibility_element( name=locator.name, role=locator.role, app_name=locator.app_name, ) - except (OSError, RuntimeError, ValueError): + except _LOCATE_ERRORS: return None if element is None: return None diff --git a/je_auto_control/utils/assertion/combinators.py b/je_auto_control/utils/assertion/combinators.py index eb3c7645..05460a40 100644 --- a/je_auto_control/utils/assertion/combinators.py +++ b/je_auto_control/utils/assertion/combinators.py @@ -185,7 +185,14 @@ def assert_eventually(spec: Mapping[str, Any], """ if timeout < 0: raise AutoControlAssertionException("timeout must be non-negative") - poll = max(float(interval), 0.0) + # timeout 有驗證,interval 卻被 max(..., 0.0) 靜默吞掉:負值或 0 + # 會變成 sleep(0),迴圈空轉燒滿一顆核心(實測 0.4 秒內 244k 次)。 + # timeout was validated but interval was silently clamped by + # max(..., 0.0), so 0 or a negative turned the loop into a busy-spin — + # measured at 244k attempts in 0.4s, each a real assertion evaluation. + if interval <= 0: + raise AutoControlAssertionException("interval must be positive") + poll = float(interval) deadline = time.monotonic() + float(timeout) attempts = 0 last: Optional[AssertionResult] = None diff --git a/je_auto_control/utils/callback/callback_function_executor.py b/je_auto_control/utils/callback/callback_function_executor.py index c5be0d90..e6703dae 100644 --- a/je_auto_control/utils/callback/callback_function_executor.py +++ b/je_auto_control/utils/callback/callback_function_executor.py @@ -3,7 +3,7 @@ # utils cv2_utils from je_auto_control.utils.cv2_utils.screenshot import pil_screenshot from je_auto_control.utils.exception.exception_tags import get_bad_trigger_method_error_message, get_bad_trigger_function_error_message -from je_auto_control.utils.exception.exceptions import CallbackExecutorException +from je_auto_control.utils.exception.exceptions import AutoControlException, CallbackExecutorException from je_auto_control.utils.logging.logging_instance import autocontrol_logger # executor from je_auto_control.utils.executor.action_executor import execute_action, execute_files @@ -151,35 +151,49 @@ def callback_function( :param callback_function_param: 回呼函式的參數 (dict 或 list) :param callback_param_method: 回呼函式參數傳遞方式 ("kwargs" 或 "args") :param kwargs: 傳給 trigger_function 的參數 - :return: trigger_function 的回傳值 + :return: trigger_function 的回傳值 (若 trigger 失敗則為 None) """ try: if trigger_function_name not in self.event_dict: raise CallbackExecutorException(get_bad_trigger_function_error_message) - - # 執行 trigger function - execute_return_value = self.event_dict[trigger_function_name](**kwargs) - - # 呼叫 callback function - if callback_function_param is not None: - if callback_param_method not in ["kwargs", "args"]: - raise CallbackExecutorException(get_bad_trigger_method_error_message) - if callback_param_method == "kwargs": - callback_function(**callback_function_param) - else: - callback_function(*callback_function_param) - else: - callback_function() - - return execute_return_value - + if (callback_function_param is not None + and callback_param_method not in ("kwargs", "args")): + raise CallbackExecutorException(get_bad_trigger_method_error_message) except CallbackExecutorException as error: autocontrol_logger.error("callback_function config error: %r", error) return None - except (TypeError, ValueError, RuntimeError) as error: - autocontrol_logger.error("callback_function execution failed: %r", error) + + # Run the trigger in its own scope: a trigger failure (including the + # framework family — mouse / image / assertion errors) returns None and + # is never confused with a later callback failure. + try: + execute_return_value = self.event_dict[trigger_function_name](**kwargs) + except (AutoControlException, TypeError, ValueError, RuntimeError) as error: + autocontrol_logger.error("callback_function trigger failed: %r", error) return None + # Run the callback in its own scope: a callback failure must not discard + # the already-computed trigger result. + try: + self._invoke_callback( + callback_function, callback_function_param, callback_param_method) + except (AutoControlException, TypeError, ValueError, RuntimeError) as error: + autocontrol_logger.error("callback_function callback failed: %r", error) + + return execute_return_value + + @staticmethod + def _invoke_callback(callback_function: Callable, + callback_function_param: dict | None, + callback_param_method: str) -> None: + """Invoke ``callback_function`` with the configured parameter passing.""" + if callback_function_param is None: + callback_function() + elif callback_param_method == "kwargs": + callback_function(**callback_function_param) + else: + callback_function(*callback_function_param) + # === 全域 Callback Executor 實例 Global Instance === callback_executor = CallbackFunctionExecutor() diff --git a/je_auto_control/utils/chatops/handlers.py b/je_auto_control/utils/chatops/handlers.py index 8a14ea62..2615a456 100644 --- a/je_auto_control/utils/chatops/handlers.py +++ b/je_auto_control/utils/chatops/handlers.py @@ -88,12 +88,24 @@ def cmd_status(_argv: List[str], return CommandResult(text="no recent runs.") lines = [ f" [{row.status}] {row.source_type}:{row.source_id} " - f"@ {row.started_at} ({row.duration_seconds:.1f}s)" + f"@ {row.started_at} ({_format_duration(row.duration_seconds)})" for row in rows ] return CommandResult(text="recent runs:\n" + "\n".join(lines)) +def _format_duration(duration_seconds: Any) -> str: + """Render a run duration, tolerating an in-flight/crashed run's ``None``. + + ``duration_seconds`` is ``None`` while a run is still going or when the + process died before ``finish_run`` — formatting that with ``:.1f`` raised + an uncaught ``TypeError`` that took the whole bot poll loop down. + """ + if duration_seconds is None: + return "in progress" + return f"{duration_seconds:.1f}s" + + def cmd_screenshot(argv: List[str], _context: Dict[str, Any]) -> CommandResult: """``/screenshot [path]`` — capture the screen and return the path.""" diff --git a/je_auto_control/utils/chatops/router.py b/je_auto_control/utils/chatops/router.py index 5d4e7ba8..4fdfd583 100644 --- a/je_auto_control/utils/chatops/router.py +++ b/je_auto_control/utils/chatops/router.py @@ -10,10 +10,13 @@ from __future__ import annotations import shlex +import sqlite3 import threading from dataclasses import asdict, dataclass, field from typing import Any, Callable, Dict, List, Optional +from je_auto_control.utils.exception.exceptions import AutoControlException + # Built-in commands always available even when the operator only # registers their own scripts. Keep this set small — listing scripts @@ -133,11 +136,16 @@ def _dispatch_argv(self, argv: List[str], f"{spec.required_role!r}; you do not have it."), succeeded=False, ) + # The router is the containment boundary for handler failures: a bad + # script (AutoControlException) or a run-history read error + # (sqlite3.Error) must come back as a chat reply, not escape and kill + # the transport's poll loop. try: return spec.handler(rest, context) except ChatOpsError as error: return CommandResult(text=f"{name}: {error}", succeeded=False) - except (RuntimeError, OSError, ValueError) as error: + except (RuntimeError, OSError, ValueError, TypeError, + AutoControlException, sqlite3.Error) as error: return CommandResult( text=f"{name} failed: {type(error).__name__}: {error}", succeeded=False, diff --git a/je_auto_control/utils/chatops/slack_bot.py b/je_auto_control/utils/chatops/slack_bot.py index 9ace4bb2..52ddedca 100644 --- a/je_auto_control/utils/chatops/slack_bot.py +++ b/je_auto_control/utils/chatops/slack_bot.py @@ -27,6 +27,7 @@ from typing import Any, Dict, Optional from je_auto_control.utils.chatops.router import CommandResult, CommandRouter +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger @@ -72,18 +73,21 @@ def poll_once(self) -> int: # Slack returns newest-first; reverse so we route in chronological order. ordered = list(reversed(messages)) dispatched = 0 - latest_ts = self.last_seen_ts or "0" for msg in ordered: ts = str(msg.get("ts") or "") if not ts: continue - latest_ts = max(latest_ts, ts) + # Commit progress *before* running the command. A command's side + # effects (running a script, taking a screenshot) happen exactly + # once inside _route_one; if the handler or the reply post then + # fails, the next poll must not re-execute it. Advancing last_seen_ts + # here (Slack's `oldest` is exclusive) guarantees at-most-once. + self.last_seen_ts = max(self.last_seen_ts or "0", ts) if self._is_self(msg): continue text = str(msg.get("text") or "") if self._route_one(text, msg) is not None: dispatched += 1 - self.last_seen_ts = latest_ts return dispatched def run_forever(self, *, max_iterations: Optional[int] = None) -> None: @@ -110,13 +114,16 @@ def stop(self) -> None: def _route_one(self, text: str, message: Dict[str, Any]) -> Optional[CommandResult]: + # The router already converts handler failures into CommandResults; + # this is defence in depth so a framework error surfaces as a chat + # reply rather than escaping poll_once and stopping run_forever. try: result = self.router.dispatch( text, context={"slack_user": message.get("user"), "slack_ts": message.get("ts"), "slack_channel": self.channel_id}, ) - except (RuntimeError, ValueError) as error: + except (RuntimeError, ValueError, AutoControlException) as error: self.post_message(f"router error: {error}") return None if result is None: diff --git a/je_auto_control/utils/codegen/codegen.py b/je_auto_control/utils/codegen/codegen.py index 72a2f6b6..e5986197 100644 --- a/je_auto_control/utils/codegen/codegen.py +++ b/je_auto_control/utils/codegen/codegen.py @@ -72,14 +72,24 @@ def _body(actions: Sequence, style: str) -> str: raise ValueError(f"unknown codegen style: {style!r}") -def _render_pytest(actions: Sequence, name: str, style: str) -> str: - body = textwrap.indent(_body(actions, style), " ") +def _render_pytest(actions: Sequence, name: str, style: str, + failure_bundle: bool = False) -> str: + raw_body = _body(actions, style) + if failure_bundle: + body = (" with ac.failure_bundle_on_error(\n" + f" {(_slug(name) + '-failure.zip')!r},\n" + f" context={{'generated_test': {_slug(name)!r}}}):\n" + + textwrap.indent(raw_body, " ")) + else: + body = textwrap.indent(raw_body, " ") return (f'"""{_HEADER}"""\n' - "import je_auto_control as ac\n\n\n" - f"def test_{_slug(name)}():\n{body}\n") + + ("import je_auto_control.api as ac\n\n\n" if failure_bundle + else "import je_auto_control as ac\n\n\n") + + f"def test_{_slug(name)}():\n{body}\n") -def _render_python(actions: Sequence, name: str, style: str) -> str: +def _render_python(actions: Sequence, name: str, style: str, + _failure_bundle: bool = False) -> str: slug = _slug(name) body = textwrap.indent(_body(actions, style), " ") return (f'"""{_HEADER}"""\n' @@ -89,7 +99,8 @@ def _render_python(actions: Sequence, name: str, style: str) -> str: f" {slug}()\n") -def _render_robot(actions: Sequence, name: str, _style: str) -> str: +def _render_robot(actions: Sequence, name: str, _style: str, + _failure_bundle: bool = False) -> str: payload = json.dumps([list(action) for action in actions], ensure_ascii=False) test_name = name.replace("_", " ").strip().title() or "Recorded Flow" @@ -113,22 +124,27 @@ def _render_robot(actions: Sequence, name: str, _style: str) -> str: def generate_code(actions: Sequence, target: str = "pytest", - name: str = "recorded_flow", style: str = "calls") -> str: + name: str = "recorded_flow", style: str = "calls", + failure_bundle: bool = False) -> str: """Render ``actions`` as source code for ``target`` (pytest/python/robot).""" if not isinstance(actions, list) or not actions: raise ValueError("actions must be a non-empty list") renderer = _RENDERERS.get(target) if renderer is None: raise ValueError(f"unknown codegen target: {target!r}") - return renderer(actions, name, style) + if failure_bundle and target != "pytest": + raise ValueError("failure_bundle is currently supported for pytest only") + return renderer(actions, name, style, failure_bundle) def generate_code_file(source, output_path: str, target: str = "pytest", - name: str = "recorded_flow", style: str = "calls") -> str: + name: str = "recorded_flow", style: str = "calls", + failure_bundle: bool = False) -> str: """Generate code from a list or JSON action-file path; write and return it.""" actions = source if isinstance(source, list) else read_action_json( os.path.realpath(source)) - code = generate_code(actions, target=target, name=name, style=style) + code = generate_code(actions, target=target, name=name, style=style, + failure_bundle=failure_bundle) with open(os.path.realpath(output_path), "w", encoding="utf-8") as handle: handle.write(code) return code diff --git a/je_auto_control/utils/config_redaction/config_redaction.py b/je_auto_control/utils/config_redaction/config_redaction.py index 9222a2c7..6559fbaf 100644 --- a/je_auto_control/utils/config_redaction/config_redaction.py +++ b/je_auto_control/utils/config_redaction/config_redaction.py @@ -44,6 +44,21 @@ def redact_config(obj: Any, *, mask: str = _DEFAULT_MASK) -> Any: def redact_secret_text(text: str, *, mask: str = _DEFAULT_MASK) -> str: """Mask secret-looking tokens within a free-text string (e.g. a log line).""" + # Explicit credential syntax must be masked even when the value is short or + # low-entropy and therefore intentionally below the generic scanner's + # threshold (common in tests, local deployments, and leaked error text). + text = re.sub( + r"(?i)(\bauthorization\s*:\s*bearer\s+)[^\s,;]+", + lambda match: match.group(1) + mask, + text or "", + ) + text = re.sub( + r"(?i)(\b(?:api[_-]?key|access[_-]?token|token|password|passwd|secret)" + r"\s*[=:]\s*)([^\s,;]+)", + lambda match: match.group(1) + mask, + text, + ) + def _replace(match: "re.Match[str]") -> str: token = match.group(0) core = token.strip(_PUNCT) @@ -51,4 +66,4 @@ def _replace(match: "re.Match[str]") -> str: return token.replace(core, mask) return token - return re.sub(r"\S+", _replace, text or "") + return re.sub(r"\S+", _replace, text) diff --git a/je_auto_control/utils/critical_exit/critical_exit.py b/je_auto_control/utils/critical_exit/critical_exit.py index 89e1c925..2f11775c 100644 --- a/je_auto_control/utils/critical_exit/critical_exit.py +++ b/je_auto_control/utils/critical_exit/critical_exit.py @@ -1,10 +1,15 @@ import _thread -from threading import Thread +from threading import Event, Thread +from typing import Union from je_auto_control.utils.logging.logging_instance import autocontrol_logger -from je_auto_control.wrapper.auto_control_keyboard import keyboard_keys_table +from je_auto_control.wrapper.auto_control_keyboard import _resolve_keycode from je_auto_control.wrapper.platform_wrapper import keyboard_check +# 輪詢間隔,避免佔滿一顆 CPU 核心 +# Poll interval; without it the listener busy-spins and pegs a CPU core. +_POLL_INTERVAL_SECONDS: float = 0.02 + class CriticalExit(Thread): """ @@ -20,37 +25,68 @@ def __init__(self, default_daemon: bool = True): Initialize CriticalExit :param default_daemon: 是否設為守護執行緒 (程式結束時自動停止) + :raises AutoControlCantFindKeyException: 平台鍵盤對應表缺少預設的 f7 鍵 """ super().__init__() self.daemon = default_daemon # 預設退出鍵為 F7 Default exit key is F7 - self._exit_check_key: int = keyboard_keys_table.get("f7") + self._exit_check_key: int = _resolve_keycode("f7") + self._stop_event = Event() - def set_critical_key(self, keycode: int | str = None) -> None: + def set_critical_key(self, keycode: Union[int, str] = None) -> None: """ 設定退出鍵 Set critical exit key + 傳入 None 時維持原本的按鍵不變。 + Passing None leaves the current key unchanged. + :param keycode: 可傳入 int (keycode) 或 str (鍵名) + :raises AutoControlCantFindKeyException: 找不到對應的鍵名 + """ + if keycode is None: + return + # 解析失敗必須立刻拋出:靜默存入 None 會讓監聽執行緒在輪詢時死亡, + # 使緊急退出鍵無聲失效。 + # Resolve eagerly: silently storing None would kill the listener + # thread on its first poll, leaving the panic key dead with no signal. + self._exit_check_key = _resolve_keycode(keycode) + + def stop(self) -> None: + """ + 停止監聽器 + Stop the listener loop """ - if isinstance(keycode, int): - self._exit_check_key = keycode - elif isinstance(keycode, str): - self._exit_check_key = keyboard_keys_table.get(keycode) + self._stop_event.set() def run(self) -> None: """ 執行監聽迴圈 Run listener loop - - 持續監聽指定鍵盤按鍵 - - 當按下時觸發中斷主程式 + - 以固定間隔輪詢指定鍵盤按鍵 + - 按下時中斷主程式一次後結束監聽 """ try: - while True: + # wait() 兼作節流與停止訊號:回傳 True 代表已呼叫 stop()。 + # wait() doubles as the throttle and the stop signal: it returns + # True only once stop() has been called. + while not self._stop_event.wait(_POLL_INTERVAL_SECONDS): if keyboard_check.check_key_is_press(self._exit_check_key): - _thread.interrupt_main() # 中斷主程式 Interrupt main thread - except (OSError, RuntimeError) as error: - autocontrol_logger.error("critical exit listener failed: %r", error) + # 只中斷一次。持續中斷會打斷主程式的 KeyboardInterrupt + # 處理與清理程式碼。 + # Interrupt once. Firing every poll while the key is still + # held would interrupt the main thread's own + # KeyboardInterrupt handler and its cleanup code. + _thread.interrupt_main() + return + # 守護執行緒無法將例外往外拋,靜默死亡會讓緊急退出鍵失效, + # 因此刻意攔截所有例外並完整記錄。 + # A daemon listener cannot propagate anything to the caller; dying + # silently is the exact failure this class must avoid, so catch + # broadly and log loudly. + except Exception as error: # noqa: BLE001 # reason: see comment above + autocontrol_logger.error( + "critical exit listener failed: %r", error, exc_info=True) def init_critical_exit(self) -> None: """ diff --git a/je_auto_control/utils/cv2_utils/screen_record.py b/je_auto_control/utils/cv2_utils/screen_record.py index 22cc04d3..e46bd51a 100644 --- a/je_auto_control/utils/cv2_utils/screen_record.py +++ b/je_auto_control/utils/cv2_utils/screen_record.py @@ -33,13 +33,15 @@ def start_new_record( :param frame_per_sec: 每秒幀數 :param resolution: 解析度 (寬, 高) """ - record_thread = ScreenRecordThread(path_and_filename, codec, frame_per_sec, resolution) - - # 如果已有同名錄影器,先停止舊的 - old_record = self.running_recorder.get(recorder_name) + # 先停止並等待舊的同名錄影器,避免兩個 VideoWriter 同時寫入同一檔案。 + # Stop (and wait for) any existing recorder first so we never open a + # second VideoWriter on the same file before the old one releases it. + old_record = self.running_recorder.pop(recorder_name, None) if old_record is not None: old_record.stop() + old_record.join(timeout=5.0) + record_thread = ScreenRecordThread(path_and_filename, codec, frame_per_sec, resolution) record_thread.daemon = True record_thread.start() self.running_recorder[recorder_name] = record_thread @@ -49,9 +51,13 @@ def stop_record(self, recorder_name: str): Stop a specific recorder 停止指定的錄影器 """ - if recorder_name in self.running_recorder: - self.running_recorder[recorder_name].stop() - del self.running_recorder[recorder_name] + record_thread = self.running_recorder.pop(recorder_name, None) + if record_thread is not None: + # Join after stopping so the VideoWriter is released (its run() + # finally) before we return — otherwise the .avi may still be + # unflushed when the caller reads/uploads it. + record_thread.stop() + record_thread.join(timeout=5.0) class ScreenRecordThread(threading.Thread): @@ -65,13 +71,16 @@ def __init__(self, path_and_filename, codec, frame_per_sec, resolution: Tuple[in super().__init__() self.fourcc = cv2.VideoWriter.fourcc(*codec) self.video_writer = cv2.VideoWriter(path_and_filename, self.fourcc, frame_per_sec, resolution) - self.record_flag = False + # 用 Event 而非布林旗標:run() 之前呼叫 stop() 也能被遵守,不會被覆寫。 + # An Event, not a bool flag, so a stop() that lands before run() starts + # is honoured instead of being overwritten by run() (which would leave + # the recorder unstoppable and the VideoWriter never released). + self._stop_event = threading.Event() self.resolution = resolution def run(self) -> None: - self.record_flag = True try: - while self.record_flag: + while not self._stop_event.is_set(): # 擷取螢幕畫面 Capture screen frame image = screenshot() @@ -89,4 +98,4 @@ def stop(self) -> None: Stop recording 停止錄影 """ - self.record_flag = False \ No newline at end of file + self._stop_event.set() \ No newline at end of file diff --git a/je_auto_control/utils/cv2_utils/screenshot.py b/je_auto_control/utils/cv2_utils/screenshot.py index c6158091..0c10ccbd 100644 --- a/je_auto_control/utils/cv2_utils/screenshot.py +++ b/je_auto_control/utils/cv2_utils/screenshot.py @@ -1,7 +1,32 @@ from PIL import ImageGrab, Image from typing import List, Optional -from je_auto_control.utils.logging.logging_instance import autocontrol_logger +from je_auto_control.utils.exception.exceptions import AutoControlScreenException + + +def _validate_region(screen_region: List[int]) -> None: + """Reject a region PIL would turn into an empty image. + + 擷取區域必須是 [左, 上, 右, 下] 且寬高都大於 0。 + A zero-area bbox makes ImageGrab return an empty image, and the caller's + cv2.cvtColor then raises a raw ``cv2.error`` — which subclasses only + ``Exception``, so it escapes the screenshot wrapper's except tuple and + reaches callers that were told to expect AutoControlScreenException. + Reject it here, at the boundary, with a message that says what is wrong. + """ + try: + left, top, right, bottom = (int(value) for value in screen_region) + except (TypeError, ValueError) as error: + raise AutoControlScreenException( + f"screen_region must be 4 ints [left, top, right, bottom]; " + f"got {screen_region!r}" + ) from error + if right <= left or bottom <= top: + raise AutoControlScreenException( + f"screen_region must have positive width and height; got " + f"[{left}, {top}, {right}, {bottom}] " + f"(width={right - left}, height={bottom - top})" + ) def pil_screenshot(file_path: Optional[str] = None, screen_region: Optional[List[int]] = None) -> Image.Image: @@ -17,15 +42,20 @@ def pil_screenshot(file_path: Optional[str] = None, screen_region: Optional[List """ # 擷取螢幕畫面 Capture screen if screen_region is not None: + _validate_region(screen_region) image = ImageGrab.grab(bbox=screen_region) else: image = ImageGrab.grab() - # 如果指定了存檔路徑,則存檔 Save if file_path is provided + # 如果指定了存檔路徑,則存檔 Save if file_path is provided. + # Fail fast: a swallowed save error would leave the caller believing the + # requested screenshot file exists when it does not. if file_path: try: image.save(file_path) except (OSError, ValueError) as error: - autocontrol_logger.error("Failed to save screenshot: %r", error) + raise AutoControlScreenException( + f"Failed to save screenshot to {file_path!r}: {error}" + ) from error return image \ No newline at end of file diff --git a/je_auto_control/utils/dag/runner.py b/je_auto_control/utils/dag/runner.py index e2f6f8a6..08719d9d 100644 --- a/je_auto_control/utils/dag/runner.py +++ b/je_auto_control/utils/dag/runner.py @@ -16,6 +16,7 @@ from je_auto_control.utils.dag.graph import ( DagDefinition, DagDefinitionError, DagNode, parse_definition, ) +from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.logging.logging_instance import autocontrol_logger @@ -118,22 +119,21 @@ def _execute_with_pool(dag: DagDefinition, """Schedule nodes whose deps are all done; cascade skip on failure.""" pending: Set[str] = set(results) inflight: Dict[Future, str] = {} - failed_ancestors: Dict[str, Set[str]] = _ancestor_index(dag) + ancestors: Dict[str, Set[str]] = _ancestor_index(dag) nodes_by_id = dag.by_id() - failed: Set[str] = set() with ThreadPoolExecutor(max_workers=max_parallel) as pool: while pending or inflight: _spawn_ready_nodes( - pending, inflight, results, failed, - failed_ancestors, nodes_by_id, local, remote, pool, + pending, inflight, results, + ancestors, nodes_by_id, local, remote, pool, ) if not inflight: continue - _harvest_one(inflight, results, failed) + _harvest_one(inflight, results) def _spawn_ready_nodes(pending: Set[str], inflight: Dict[Future, str], - results: Dict[str, NodeResult], failed: Set[str], + results: Dict[str, NodeResult], ancestors: Dict[str, Set[str]], nodes_by_id: Dict[str, DagNode], local: NodeRunner, remote: NodeRunner, @@ -143,7 +143,7 @@ def _spawn_ready_nodes(pending: Set[str], inflight: Dict[Future, str], deps_done = {results[d].status for d in node.depends_on} if {STATUS_PENDING, STATUS_RUNNING} & deps_done: continue - if ancestors[nid] & failed: + if _blocked_by_ancestor(ancestors[nid], results): results[nid].status = STATUS_SKIPPED pending.discard(nid) continue @@ -155,27 +155,44 @@ def _spawn_ready_nodes(pending: Set[str], inflight: Dict[Future, str], pending.discard(nid) +def _blocked_by_ancestor(ancestor_ids: Set[str], + results: Dict[str, NodeResult]) -> bool: + """True if any ancestor already failed or was skipped. + + Reads the ancestors' live status rather than a separately-maintained + "failed" set, so the skip cascade is deterministic even when a node + fails fast inside its worker thread before the harvest loop observes it. + """ + return any( + results[aid].status in (STATUS_FAILED, STATUS_SKIPPED) + for aid in ancestor_ids + ) + + def _harvest_one(inflight: Dict[Future, str], - results: Dict[str, NodeResult], - failed: Set[str]) -> None: + results: Dict[str, NodeResult]) -> None: finished = next(iter(inflight)) nid = inflight.pop(finished) try: finished.result() - except (RuntimeError, OSError, ValueError) as error: + except (AutoControlException, RuntimeError, OSError, ValueError) as error: # Defensive: _run_one swallows tool errors into NodeResult. - # This branch only catches pool / runner-side faults. + # This branch only catches pool / runner-side faults. It includes + # AutoControlException so a framework failure that slips past + # _run_one still fails the node instead of tearing down run_dag. results[nid].status = STATUS_FAILED results[nid].error = repr(error) - if results[nid].status == STATUS_FAILED: - failed.add(nid) def _run_one(node: DagNode, result: NodeResult, runner: NodeRunner, _nodes: Dict[str, DagNode]) -> None: try: outcome = runner(node, _build_proxy_definition(node, _nodes)) - except (RuntimeError, OSError, ValueError) as error: + except (AutoControlException, RuntimeError, OSError, ValueError) as error: + # AutoControlException covers validate_actions / locate / assert + # failures raised by the executor: a node failure becomes a failed + # NodeResult (and a downstream skip cascade), not a raw crash that + # leaves the node stuck "running" and returns no DagRunResult. result.status = STATUS_FAILED result.error = f"{type(error).__name__}: {error}" autocontrol_logger.warning( diff --git a/je_auto_control/utils/data_source/data_source.py b/je_auto_control/utils/data_source/data_source.py index 54e525b4..80f24de4 100644 --- a/je_auto_control/utils/data_source/data_source.py +++ b/je_auto_control/utils/data_source/data_source.py @@ -24,8 +24,10 @@ import json import os import sqlite3 +from contextlib import closing from pathlib import Path from typing import Any, Callable, Dict, List, Optional +from urllib.parse import quote _READ_ONLY_SQL_PREFIXES = ("select", "with") @@ -86,8 +88,15 @@ def _load_sqlite(source: Dict[str, Any]) -> List[Dict[str, Any]]: """Run a read-only SELECT against a SQLite file and return dict rows.""" path = _resolve_path(source["path"]) query = _validate_select(str(source["query"])) - uri = f"file:{path}?mode=ro" - with sqlite3.connect(uri, uri=True) as conn: + # SQLite treats ``?`` as the query separator and percent-decodes ``%XX`` in + # the path, so a raw f-string opens the wrong file when the path contains + # ``?``/``#``/``%``. Percent-encode those while keeping the separators and + # the Windows drive colon literal. + safe_path = quote(str(path), safe="/:\\") + uri = f"file:{safe_path}?mode=ro" + # closing(): the sqlite3 context manager commits/rolls back but never closes + # the connection, so the file handle leaks until GC. Close it explicitly. + with closing(sqlite3.connect(uri, uri=True)) as conn: conn.row_factory = sqlite3.Row rows = conn.execute(query).fetchall() return [dict(row) for row in rows] diff --git a/je_auto_control/utils/dedup_window/dedup_window.py b/je_auto_control/utils/dedup_window/dedup_window.py index d9f2ab10..b4778810 100644 --- a/je_auto_control/utils/dedup_window/dedup_window.py +++ b/je_auto_control/utils/dedup_window/dedup_window.py @@ -31,7 +31,9 @@ def seen(self, message_id: str) -> bool: """Whether ``message_id`` is in the window and not expired.""" now = self._clock() self._purge(now) - return message_id in self._seen + # Coerce on lookup exactly as mark() coerces on store, so an int id + # (123) matches the "123" that was stored instead of never deduping. + return str(message_id) in self._seen def mark(self, message_id: str) -> None: """Record ``message_id`` as seen now.""" @@ -41,9 +43,10 @@ def check_and_mark(self, message_id: str) -> bool: """Atomically return ``True`` if first-seen (and mark), else ``False``.""" now = self._clock() self._purge(now) - if message_id in self._seen: + key = str(message_id) + if key in self._seen: return False - self._seen[str(message_id)] = now + self._seen[key] = now return True def purge_expired(self) -> int: diff --git a/je_auto_control/utils/deprecation.py b/je_auto_control/utils/deprecation.py new file mode 100644 index 00000000..c4084c68 --- /dev/null +++ b/je_auto_control/utils/deprecation.py @@ -0,0 +1,35 @@ +"""Consistent deprecation warnings for public AutoControl APIs.""" +from __future__ import annotations + +import functools +import warnings +from typing import Callable, TypeVar, cast + +F = TypeVar("F", bound=Callable) + + +class AutoControlDeprecationWarning(FutureWarning): + """A user-visible warning for an API scheduled for removal.""" + + +def deprecated(*, since: str, removal: str, replacement: str = ""): + """Mark a callable deprecated with actionable lifecycle metadata.""" + def decorate(func: F) -> F: + message = f"{func.__qualname__} is deprecated since {since}" + message += f" and will be removed in {removal}." + if replacement: + message += f" Use {replacement} instead." + + @functools.wraps(func) + def wrapped(*args, **kwargs): + warnings.warn(message, AutoControlDeprecationWarning, + stacklevel=2) + return func(*args, **kwargs) + wrapped.__deprecated__ = { # type: ignore[attr-defined] + "since": since, "removal": removal, "replacement": replacement, + } + return cast(F, wrapped) + return decorate + + +__all__ = ["AutoControlDeprecationWarning", "deprecated"] diff --git a/je_auto_control/utils/exception/exceptions.py b/je_auto_control/utils/exception/exceptions.py index 6a4a755b..a5e8891c 100644 --- a/je_auto_control/utils/exception/exceptions.py +++ b/je_auto_control/utils/exception/exceptions.py @@ -1,99 +1,106 @@ # general class AutoControlException(Exception): - pass + """Base class for every AutoControl runtime error. + + All framework exceptions derive from this so that containment boundaries + (executor, background poll loops, request handlers, GUI slots) can catch + the whole family with a single ``except AutoControlException``. Do not add + a sibling that inherits ``Exception`` directly — that silently escapes + every such boundary. + """ # Keyboard -class AutoControlKeyboardException(Exception): +class AutoControlKeyboardException(AutoControlException): pass -class AutoControlCantFindKeyException(Exception): +class AutoControlCantFindKeyException(AutoControlException): pass # Mouse -class AutoControlMouseException(Exception): +class AutoControlMouseException(AutoControlException): pass # Screen -class AutoControlScreenException(Exception): +class AutoControlScreenException(AutoControlException): pass # Image detect -class ImageNotFoundException(Exception): +class ImageNotFoundException(AutoControlException): pass # Record -class AutoControlRecordException(Exception): +class AutoControlRecordException(AutoControlException): pass # Execute action -class AutoControlExecuteActionException(Exception): +class AutoControlExecuteActionException(AutoControlException): pass -class AutoControlJsonActionException(Exception): +class AutoControlJsonActionException(AutoControlException): pass -class AutoControlActionNullException(Exception): +class AutoControlActionNullException(AutoControlException): pass -class AutoControlActionException(Exception): +class AutoControlActionException(AutoControlException): pass -class AutoControlAddCommandException(Exception): +class AutoControlAddCommandException(AutoControlException): pass -class AutoControlAssertionException(Exception): +class AutoControlAssertionException(AutoControlException): """Raised when an ``AC_assert_*`` check fails.""" -class AutoControlArgparseException(Exception): +class AutoControlArgparseException(AutoControlException): pass # html exception -class AutoControlHTMLException(Exception): +class AutoControlHTMLException(AutoControlException): pass # Json Exception -class AutoControlJsonException(Exception): +class AutoControlJsonException(AutoControlException): pass -class AutoControlGenerateJsonReportException(Exception): +class AutoControlGenerateJsonReportException(AutoControlException): pass # XML -class XMLException(Exception): +class XMLException(AutoControlException): pass -class XMLTypeException(Exception): +class XMLTypeException(AutoControlException): pass # Execute callback -class CallbackExecutorException(Exception): +class CallbackExecutorException(AutoControlException): pass diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 639a3acf..065e714e 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -7,7 +7,8 @@ ) from je_auto_control.utils.exception.exceptions import ( AutoControlActionException, AutoControlAddCommandException, - AutoControlActionNullException + AutoControlActionNullException, AutoControlAssertionException, + AutoControlException ) from je_auto_control.utils.accessibility.accessibility_api import ( click_accessibility_element, find_accessibility_element, @@ -52,7 +53,7 @@ from je_auto_control.utils.run_history.history_store import default_history_store from je_auto_control.utils.secrets import default_secret_manager from je_auto_control.utils.script_vars.interpolate import ( - interpolate_actions, interpolate_value, + interpolate_value, ) from je_auto_control.utils.script_vars.scope import VariableScope from je_auto_control.utils.http_client.http_client import http_request @@ -4677,7 +4678,15 @@ def _expect_poll(action: Any, key: Any = None, op: str = "truthy", matcher = builders.get(str(op), to_be_truthy)() def getter(): - record = executor.execute_action([list(action)]) + try: + record = executor.execute_action([list(action)], raise_on_error=True) + except (AutoControlException, OSError, RuntimeError, ValueError, + LookupError): + # A failed polled action is "not yet satisfied", not a value. + # With raise_on_error=False the record would hold repr(error) — a + # truthy string that satisfies the default truthy matcher and ends + # the poll on the first attempt instead of waiting for success. + return None value = next(iter(record.values()), None) if key is not None and isinstance(value, dict): return value.get(key) @@ -6827,8 +6836,11 @@ class Executor: # Args keys that hold nested action lists; runtime interpolation must # leave them untouched so each iteration re-reads current variable state. + # ``catch``/``finally`` belong here too: AC_try only binds ``error_var`` + # once the body has failed, so eagerly interpolating them at dispatch + # would resolve ${err} before it exists. _DEFERRED_ARG_KEYS: frozenset = frozenset( - {"body", "then", "else", "branches"}) + {"body", "then", "else", "branches", "catch", "finally"}) def __init__(self): self._block_commands = BLOCK_COMMANDS @@ -7705,12 +7717,14 @@ def known_commands(self) -> set: def _resolve_runtime_args(self, args: Any) -> Any: """Interpolate ``${var}`` placeholders against the current scope. - Keys inside :attr:`_DEFERRED_ARG_KEYS` (``body``/``then``/``else``) - are left as-is so nested action lists keep their placeholders for - per-iteration evaluation. + Keys inside :attr:`_DEFERRED_ARG_KEYS` are left as-is so nested + action lists keep their placeholders for per-iteration evaluation. + + An empty scope is not a reason to skip interpolation: ``${secrets.*}`` + resolves through the vault without consulting the scope at all, and an + unknown ``${var}`` must raise rather than reach the automation as a + literal placeholder. """ - if not self.variables: - return args if isinstance(args, dict): resolved: Dict[str, Any] = {} for key, value in args.items(): @@ -7802,6 +7816,15 @@ def _run_one_action(self, action: list, record: Dict[str, Any], """Execute a single action, recording the result or raising.""" import time as _time key = "execute: " + str(action) + if key in record: + # Two byte-identical actions would otherwise share one record slot, + # so an earlier failure is silently overwritten by a later success. + # The first occurrence keeps the bare key (unchanged for callers); + # repeats get a numeric suffix so every outcome is preserved. + suffix = 2 + while f"{key} #{suffix}" in record: + suffix += 1 + key = f"{key} #{suffix}" action_name = action[0] if action and isinstance(action[0], str) else "" started = _time.monotonic() try: @@ -7810,10 +7833,22 @@ def _run_one_action(self, action: list, record: Dict[str, Any], _observe_executor_metrics(action_name, started, error=None) except (LoopBreak, LoopContinue): raise - except (AutoControlActionException, OSError, RuntimeError, - AttributeError, TypeError, ValueError) as error: + # LookupError covers the KeyError/IndexError raised by block handlers + # that subscript a required arg directly (``args["name"]``). Without + # it a merely malformed action escaped raise_on_error=False and + # aborted every remaining action instead of being recorded. + # ``AutoControlException`` is the family base: image/mouse/keyboard/ + # screen/null-action failures all subclass it, so an incidental error + # is contained (recorded) here rather than aborting the whole script. + except (AutoControlException, OSError, RuntimeError, + AttributeError, TypeError, ValueError, LookupError) as error: _observe_executor_metrics(action_name, started, error=error) - if raise_on_error: + # A failed ``AC_assert_*`` (raise_on_fail=True) is a deliberate + # fail signal, not an incidental error, so it still propagates even + # under raise_on_error=False — otherwise the assertion would be + # silently neutralised. ``AC_try``/``AC_retry`` run their body with + # raise_on_error=True and still catch it via their own tuples. + if raise_on_error or isinstance(error, AutoControlAssertionException): raise autocontrol_logger.info( f"execute_action failed, action: {action}, error: {repr(error)}" @@ -7867,12 +7902,16 @@ def execute_files(execute_files_list: list) -> List[Dict[str, str]]: def execute_action_with_vars(action_list: list, variables: dict ) -> Dict[str, str]: - """Interpolate ``${name}`` placeholders with ``variables`` and execute. - - The same mapping seeds the runtime variable scope so flow-control - commands (``AC_set_var``/``AC_if_var``/...) can read and mutate the - same values during execution. + """Seed ``variables`` into the runtime scope and execute. + + Interpolation happens at dispatch time through the executor's runtime + resolver, which defers nested action bodies (loops/branches/try). Doing a + separate eager pre-pass over the whole tree here resolved deferred-body + placeholders (``${item}``, loop counters, ``error_var``) against the seed + mapping — where they do not exist yet — raising ``Unknown variable`` before + execution, and resolved ``${secrets.*}`` at load time so the plaintext then + landed in logs and record keys. Seeding the scope and letting the runtime + resolver interpolate per action fixes both. """ - resolved = interpolate_actions(action_list, variables) executor.variables.update_many(variables) - return executor.execute_action(resolved) + return executor.execute_action(action_list) diff --git a/je_auto_control/utils/executor/action_schema.py b/je_auto_control/utils/executor/action_schema.py index 458b9354..1bb9ae79 100644 --- a/je_auto_control/utils/executor/action_schema.py +++ b/je_auto_control/utils/executor/action_schema.py @@ -9,6 +9,13 @@ from je_auto_control.utils.exception.exceptions import AutoControlActionException +# Every command that runs a nested action list must appear in one of the two +# maps below. The runners execute those bodies with ``_validated=True``, +# asserting someone already validated them — so a command missing from both +# has its body validated by nobody, and a malformed nested action surfaces as +# a raw IndexError at dispatch instead of a clean rejection. + +# Keys whose value is a flat action list: [[name, params], ...] FLOW_BODY_KEYS = { "AC_if_image_found": ("then", "else"), "AC_if_pixel": ("then", "else"), @@ -19,6 +26,16 @@ "AC_retry": ("body",), "AC_try": ("body", "catch", "finally"), "AC_for_each": ("body",), + "AC_for_each_row": ("body",), + "AC_assert_duration": ("body",), + "AC_define_macro": ("body",), +} + +# Keys whose value is a LIST OF action lists — one nesting level deeper than +# FLOW_BODY_KEYS. Validating these as if they were flat would reject every +# valid action, since each element is itself a list rather than a name. +FLOW_BRANCH_LIST_KEYS = { + "AC_parallel": ("branches",), } @@ -53,10 +70,24 @@ def _validate_single(action: Any, known: Set[str], trail: str) -> None: def _validate_nested_bodies(name: str, action: list, known: Set[str], trail: str) -> None: - if name not in FLOW_BODY_KEYS or len(action) < 2 or not isinstance(action[1], dict): + if len(action) < 2 or not isinstance(action[1], dict): + return + params = action[1] + for body_key in FLOW_BODY_KEYS.get(name, ()): + body = params.get(body_key) + if body is not None: + _validate_list(body, known, f"{trail}.{body_key}") + for list_key in FLOW_BRANCH_LIST_KEYS.get(name, ()): + _validate_branch_list(params.get(list_key), known, f"{trail}.{list_key}") + + +def _validate_branch_list(branches: Any, known: Set[str], trail: str) -> None: + """Validate a list of action lists (e.g. ``AC_parallel``'s ``branches``).""" + if branches is None: + return + # The visual builder may pass a JSON string, which the runtime parses via + # _as_list. Leave that shape to the runtime rather than reject it here. + if not isinstance(branches, list): return - for body_key in FLOW_BODY_KEYS[name]: - body = action[1].get(body_key) - if body is None: - continue - _validate_list(body, known, f"{trail}.{body_key}") + for idx, branch in enumerate(branches): + _validate_list(branch, known, f"{trail}[{idx}]") diff --git a/je_auto_control/utils/executor/flow_control.py b/je_auto_control/utils/executor/flow_control.py index b40a28ab..87a816ec 100644 --- a/je_auto_control/utils/executor/flow_control.py +++ b/je_auto_control/utils/executor/flow_control.py @@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, Mapping, Optional, Sequence from je_auto_control.utils.exception.exceptions import ( - AutoControlActionException, ImageNotFoundException, + AutoControlActionException, AutoControlException, ImageNotFoundException, ) from je_auto_control.utils.logging.logging_instance import autocontrol_logger from je_auto_control.wrapper.auto_control_image import locate_image_center @@ -51,10 +51,29 @@ def _run_branch(executor: Any, body: Optional[list]) -> Any: def _run_strict(executor: Any, body: list) -> Any: - """Execute a nested body, re-raising the first error.""" + """Execute a nested body, re-raising the first error. + + An empty body is a valid no-op (matching :func:`_run_branch`); it must + not reach ``execute_action``, which rejects an empty action list. + """ + if not body: + return None return executor.execute_action(body, raise_on_error=True, _validated=True) +def _run_loop_body(executor: Any, body: Optional[list]) -> None: + """Execute a loop body once, treating an empty body as a no-op. + + A loop with an empty ``body`` is valid (it does nothing each pass) and + must not reach ``execute_action``, which raises on an empty action list. + ``AC_break`` / ``AC_continue`` raised from within the body still + propagate to the caller's loop handler. + """ + if not body: + return + executor.execute_action(body, _validated=True) + + def exec_if_image_found(executor: Any, args: Mapping[str, Any]) -> Any: """Run ``then`` when the image is present, else run ``else``.""" image = args["image"] @@ -117,7 +136,7 @@ def exec_loop(executor: Any, args: Mapping[str, Any]) -> int: completed = 0 for _ in range(times): try: - executor.execute_action(body, _validated=True) + _run_loop_body(executor, body) except LoopContinue: completed += 1 continue @@ -136,7 +155,7 @@ def exec_while_image(executor: Any, args: Mapping[str, Any]) -> int: iterations = 0 while iterations < max_iter and _image_present(image, threshold): try: - executor.execute_action(body, _validated=True) + _run_loop_body(executor, body) except LoopContinue: pass except LoopBreak: @@ -154,7 +173,7 @@ def exec_retry(executor: Any, args: Mapping[str, Any]) -> Any: for attempt in range(max_attempts): try: return _run_strict(executor, body) - except (AutoControlActionException, OSError, RuntimeError, + except (AutoControlException, OSError, RuntimeError, AttributeError, TypeError, ValueError) as error: last_error = error autocontrol_logger.info( @@ -171,9 +190,14 @@ def exec_retry(executor: Any, args: Mapping[str, Any]) -> Any: # Errors a protected block may raise that ``AC_try`` is willing to catch. # ``LoopBreak`` / ``LoopContinue`` are deliberately excluded so loop # control-flow still propagates through a try block. +# ``LookupError`` keeps this aligned with the executor's own catch tuple, so +# a KeyError/IndexError from a malformed nested action is catchable by +# ``AC_try`` rather than tearing down the whole script. ``AutoControlException`` +# is the family base (every framework error subclasses it), so image/mouse/ +# keyboard/screen/assertion failures inside the body are recoverable too. _TRY_CATCHABLE = ( - AutoControlActionException, OSError, RuntimeError, - AttributeError, TypeError, ValueError, + AutoControlException, OSError, RuntimeError, + AttributeError, TypeError, ValueError, LookupError, ) @@ -302,7 +326,7 @@ def exec_while_var(executor: Any, args: Mapping[str, Any]) -> int: iterations = 0 while iterations < max_iter and _compare_var(executor, args, "AC_while_var"): try: - executor.execute_action(body, _validated=True) + _run_loop_body(executor, body) except LoopContinue: pass except LoopBreak: @@ -324,7 +348,7 @@ def exec_for_each(executor: Any, args: Mapping[str, Any]) -> int: for item in items: executor.variables.set(var_name, item) try: - executor.execute_action(body, _validated=True) + _run_loop_body(executor, body) except LoopContinue: iterations += 1 continue @@ -355,7 +379,7 @@ def exec_for_each_row(executor: Any, args: Mapping[str, Any]) -> int: for row in rows: executor.variables.set(var_name, row) try: - executor.execute_action(body, _validated=True) + _run_loop_body(executor, body) except LoopContinue: iterations += 1 continue @@ -378,10 +402,18 @@ def exec_shell_to_var(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]: command = args.get("command", args.get("shell_command")) argv = ([str(part) for part in command] if isinstance(command, list) else shlex.split(str(command), posix=(os.name != "nt"))) - completed = subprocess.run( # nosec B603 — argv list, no shell # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit - argv, capture_output=True, check=False, - timeout=float(args.get("timeout", 30.0)), - ) + timeout_s = float(args.get("timeout", 30.0)) + try: + completed = subprocess.run( # nosec B603 — argv list, no shell # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit + argv, capture_output=True, check=False, timeout=timeout_s, + ) + except subprocess.TimeoutExpired as error: + # TimeoutExpired subclasses SubprocessError (not AutoControlException + # nor OSError), so without this it escapes every executor containment + # boundary and aborts the whole script. + raise AutoControlActionException( + f"AC_shell_to_var: command timed out after {timeout_s}s" + ) from error output = completed.stdout.decode("utf-8", errors="replace").strip() var_name = args.get("var", "shell_output") executor.variables.set(var_name, output) @@ -602,15 +634,42 @@ def exec_parallel(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]: ``branches`` is a list of action lists (or a JSON string of one). Each branch runs on a fresh :class:`Executor` with a separate variable scope, - so concurrent branches never race on shared state. + so concurrent branches never race on shared state. Custom commands + (registered via ``add_command_to_executor``) and ``AC_define_macro`` + macros are copied from the parent so a branch recognises them — otherwise + a branch's fresh executor only has the stock command set and rejects them + at runtime even though validation (against the parent) passed. """ - del executor from je_auto_control.utils.executor.action_executor import Executor + # Snapshot before spawning threads so branch workers never read the + # parent's live command/macro maps concurrently. + parent_event_dict = dict(executor.event_dict) + parent_macros = dict(executor.macros) branches = _as_list(args.get("branches")) results: list = [None] * len(branches) + errors: Dict[int, str] = {} def _run(index: int, branch: Any) -> None: - results[index] = Executor().execute_action(branch, _validated=True) + # An exception here would otherwise only kill this worker thread, + # leaving results[index] as None — indistinguishable from a branch + # that legitimately returned None, and reported as success. + try: + branch_executor = Executor() + # setdefault (not update): copy the parent's *custom* commands/macros + # while keeping the branch executor's own self-bound stock commands. + # A blind update() would overwrite AC_execute_action/AC_execute_files + # (bound to the parent) so a nested execute inside a branch would run + # against the parent's variable scope, defeating the isolation this + # function promises and reintroducing the cross-branch race. + for _name, _handler in parent_event_dict.items(): + branch_executor.event_dict.setdefault(_name, _handler) + branch_executor.macros.update(parent_macros) + results[index] = branch_executor.execute_action( + branch, _validated=True) + except Exception as error: # noqa: BLE001 # reason: see comment above + errors[index] = repr(error) + autocontrol_logger.error( + "AC_parallel branch %d failed: %r", index, error, exc_info=True) threads = [threading.Thread(target=_run, args=(idx, branch), daemon=True) for idx, branch in enumerate(branches)] @@ -618,6 +677,13 @@ def _run(index: int, branch: Any) -> None: thread.start() for thread in threads: thread.join() + if errors: + # Raise so the executor's own machinery handles it like any other + # failed command: recorded when raise_on_error is off, propagated + # when it is on. + failed = ", ".join(f"branch {idx}: {err}" for idx, err in sorted(errors.items())) + raise AutoControlActionException( + f"AC_parallel: {len(errors)} branch(es) failed: {failed}") return {"branches": len(branches), "results": results} diff --git a/je_auto_control/utils/expect_poll/expect_poll.py b/je_auto_control/utils/expect_poll/expect_poll.py index d442b2a0..5124e43c 100644 --- a/je_auto_control/utils/expect_poll/expect_poll.py +++ b/je_auto_control/utils/expect_poll/expect_poll.py @@ -37,12 +37,16 @@ def to_equal(expected: Any) -> Matcher: def to_contain(item: Any) -> Matcher: """Match when ``item`` is contained in the value.""" - return lambda value: item in value + # A getter returning None means "not ready yet" (e.g. a missing result key or + # a transiently-failing polled action). Treat it as not-matched so the poll + # keeps retrying instead of crashing with `item in None` -> TypeError. + return lambda value: value is not None and item in value def to_be_greater_than(threshold: Any) -> Matcher: """Match when the value is greater than ``threshold``.""" - return lambda value: value > threshold + # See to_contain: None is the not-ready sentinel; `None > x` would raise. + return lambda value: value is not None and value > threshold def to_match_regex(pattern: str) -> Matcher: @@ -80,7 +84,18 @@ def expect_poll(getter: Callable[[], Any], matcher: Matcher, *, Returns a :class:`PollResult` with the final value, attempt count and elapsed time. ``clock`` / ``sleep`` are injectable for deterministic tests. + + :raises ValueError: if ``interval_s`` is not positive. """ + # interval_s 必須為正。0 會讓迴圈以最快速度重跑 getter(實測 0.3 秒 + # 內 60 萬次),負值則由 time.sleep 拋出無關的錯誤訊息。此函式經 + # AC_expect_poll 暴露,interval_s 由使用者控制。 + # interval_s must be positive. 0 re-runs getter as fast as possible + # (measured ~600k iterations in 0.3s, pegging a core); a negative surfaces + # as time.sleep's own error. This is reachable via AC_expect_poll, where + # interval_s is user-controlled. + if interval_s <= 0: + raise ValueError("interval_s must be positive") start = clock() deadline = start + float(timeout_s) attempts = 0 @@ -94,7 +109,10 @@ def expect_poll(getter: Callable[[], Any], matcher: Matcher, *, if clock() >= deadline: return PollResult(False, value, attempts, round(clock() - start, 4), describe(value)) - sleep(float(interval_s)) + # Clamp the wait to the time left so a large interval_s cannot overshoot + # timeout_s (and run an extra getter well past the deadline). + remaining = deadline - clock() + sleep(min(float(interval_s), max(remaining, 0.0))) def assert_poll(getter: Callable[[], Any], matcher: Matcher, *, diff --git a/je_auto_control/utils/failure_bundle/__init__.py b/je_auto_control/utils/failure_bundle/__init__.py new file mode 100644 index 00000000..c1e4d3aa --- /dev/null +++ b/je_auto_control/utils/failure_bundle/__init__.py @@ -0,0 +1,11 @@ +"""Portable, redacted failure diagnostics.""" + +from je_auto_control.utils.failure_bundle.bundle import ( + FailureBundleOptions, + create_failure_bundle, + failure_bundle_on_error, +) + +__all__ = [ + "FailureBundleOptions", "create_failure_bundle", "failure_bundle_on_error", +] diff --git a/je_auto_control/utils/failure_bundle/bundle.py b/je_auto_control/utils/failure_bundle/bundle.py new file mode 100644 index 00000000..79035092 --- /dev/null +++ b/je_auto_control/utils/failure_bundle/bundle.py @@ -0,0 +1,176 @@ +"""Create a self-contained ZIP for diagnosing failed automation runs. + +Collection is deliberately best-effort: failure diagnostics must still be +created when screen capture, an optional backend, or the diagnostics runner +itself is broken. Text and structured values are redacted before they enter +the archive and arbitrary attachments are opt-in. +""" +from __future__ import annotations + +import json +import os +import platform +import sys +import tempfile +import time +import zipfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable, Mapping + +from je_auto_control.utils.config_redaction import ( + redact_config, + redact_secret_text, +) + + +@dataclass(frozen=True) +class FailureBundleOptions: + """Controls potentially sensitive or expensive bundle collectors.""" + + screenshot: bool = True + diagnostics: bool = True + log_path: str | None = None + log_tail_bytes: int = 256_000 + attachments: tuple[str, ...] = () + + +def _json_bytes(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, indent=2, + sort_keys=True, default=repr).encode("utf-8") + + +def _safe_name(path: Path, used: set[str]) -> str: + base = path.name or "attachment" + candidate, index = base, 2 + while candidate in used: + candidate = f"{path.stem}-{index}{path.suffix}" + index += 1 + used.add(candidate) + return candidate + + +def _read_log_tail(path: Path, limit: int) -> str: + with path.open("rb") as handle: + if path.stat().st_size > limit: + handle.seek(-limit, os.SEEK_END) + data = handle.read() + return redact_secret_text(data.decode("utf-8", errors="replace")) + + +def _collect_diagnostics(archive: zipfile.ZipFile, + failures: list[dict[str, str]]) -> None: + try: + from je_auto_control.utils.diagnostics import run_diagnostics + archive.writestr("diagnostics.json", + _json_bytes(run_diagnostics().to_dict())) + except Exception as exc: # diagnostics are best-effort + failures.append({"collector": "diagnostics", "error": repr(exc)}) + + +def _collect_screenshot(archive: zipfile.ZipFile, + failures: list[dict[str, str]]) -> None: + try: + from je_auto_control.utils.cv2_utils.screenshot import pil_screenshot + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as image_file: + image_path = image_file.name + try: + pil_screenshot().save(image_path, format="PNG") + archive.write(image_path, "screenshot.png") + finally: + Path(image_path).unlink(missing_ok=True) + except Exception as exc: # headless and locked sessions are valid + failures.append({"collector": "screenshot", "error": repr(exc)}) + + +def _collect_log(archive: zipfile.ZipFile, opts: FailureBundleOptions, + failures: list[dict[str, str]]) -> None: + try: + archive.writestr("logs/tail.log", _read_log_tail( + Path(opts.log_path or "").expanduser().resolve(), + max(1, opts.log_tail_bytes))) + except Exception as exc: + failures.append({"collector": "log", "error": repr(exc)}) + + +def _collect_attachments(archive: zipfile.ZipFile, opts: FailureBundleOptions, + failures: list[dict[str, str]]) -> None: + used: set[str] = set() + for raw_path in opts.attachments: + try: + path = Path(raw_path).expanduser().resolve(strict=True) + if not path.is_file(): + raise ValueError("attachment is not a regular file") + archive.write(path, f"attachments/{_safe_name(path, used)}") + except Exception as exc: + failures.append({"collector": "attachment", "error": repr(exc)}) + + +def _collect_all(archive: zipfile.ZipFile, opts: FailureBundleOptions, + failures: list[dict[str, str]]) -> None: + if opts.diagnostics: + _collect_diagnostics(archive, failures) + if opts.screenshot: + _collect_screenshot(archive, failures) + if opts.log_path: + _collect_log(archive, opts, failures) + _collect_attachments(archive, opts, failures) + + +def create_failure_bundle( + output_path: str | os.PathLike[str], + *, + error: BaseException | str | None = None, + context: Mapping[str, Any] | None = None, + events: Iterable[Mapping[str, Any]] = (), + options: FailureBundleOptions | None = None, +) -> str: + """Write an atomic, redacted diagnostic ZIP and return its real path.""" + opts = options or FailureBundleOptions() + target = Path(output_path).expanduser().resolve() + target.parent.mkdir(parents=True, exist_ok=True) + failures: list[dict[str, str]] = [] + manifest = { + "schema": "autocontrol.failure-bundle/v1", + "created_at_unix": time.time(), + "error": None if error is None else redact_secret_text(str(error)), + "runtime": { + "python": sys.version.split()[0], + "platform": platform.platform(), + "executable": Path(sys.executable).name, + }, + "context": redact_config(dict(context or {})), + "events": redact_config(list(events)), + "collector_failures": failures, + } + + fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", + suffix=".tmp", dir=str(target.parent)) + os.close(fd) + try: + with zipfile.ZipFile(temp_name, "w", zipfile.ZIP_DEFLATED) as archive: + _collect_all(archive, opts, failures) + archive.writestr("manifest.json", _json_bytes(manifest)) + os.replace(temp_name, target) + except BaseException: + Path(temp_name).unlink(missing_ok=True) + raise + return str(target) + + +@contextmanager +def failure_bundle_on_error( + output_path: str | os.PathLike[str], + *, + context: Mapping[str, Any] | None = None, + events: Iterable[Mapping[str, Any]] = (), + options: FailureBundleOptions | None = None, +): + """Create a bundle when the wrapped block raises, then re-raise it.""" + try: + yield + except BaseException as error: + create_failure_bundle(output_path, error=error, context=context, + events=events, options=options) + raise diff --git a/je_auto_control/utils/file_drop/file_drop.py b/je_auto_control/utils/file_drop/file_drop.py index d8fda491..c6588cb9 100644 --- a/je_auto_control/utils/file_drop/file_drop.py +++ b/je_auto_control/utils/file_drop/file_drop.py @@ -33,20 +33,41 @@ def plan_file_drop(paths: Sequence[str], *, point: Tuple[int, int] = (0, 0), "blob_size": len(blob)} +def _declare_win32_signatures(kernel32: Any, user32: Any) -> None: + """Declare argtypes/restypes so 64-bit handles aren't truncated. + + Without argtypes ctypes marshals every argument as a 32-bit ``int``, so a + 64-bit ``HGLOBAL`` / ``WPARAM`` is silently truncated — ``GlobalLock`` then + receives a bogus handle, returns ``NULL``, and ``memmove(NULL, ...)`` faults. + """ + import ctypes + from ctypes import wintypes + kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t] + kernel32.GlobalAlloc.restype = wintypes.HGLOBAL + kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL] + kernel32.GlobalLock.restype = ctypes.c_void_p + kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL] + kernel32.GlobalUnlock.restype = wintypes.BOOL + user32.PostMessageW.argtypes = [ + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, + ] + user32.PostMessageW.restype = wintypes.BOOL + + def _default_driver(hwnd: int, blob: bytes, point: Tuple[int, int]) -> bool: """Post a real ``WM_DROPFILES`` to ``hwnd`` (Windows only).""" import sys if not sys.platform.startswith("win"): raise RuntimeError("drop_files is only supported on Windows") import ctypes - from ctypes import wintypes kernel32, user32 = ctypes.windll.kernel32, ctypes.windll.user32 - kernel32.GlobalAlloc.restype = wintypes.HGLOBAL - kernel32.GlobalLock.restype = ctypes.c_void_p + _declare_win32_signatures(kernel32, user32) handle = kernel32.GlobalAlloc(_GMEM_MOVEABLE, len(blob)) if not handle: raise RuntimeError("GlobalAlloc failed") pointer = kernel32.GlobalLock(handle) + if not pointer: + raise RuntimeError("GlobalLock failed") ctypes.memmove(pointer, blob, len(blob)) kernel32.GlobalUnlock(handle) # The receiving window owns the memory and frees it via DragFinish. diff --git a/je_auto_control/utils/generate_report/generate_html_report.py b/je_auto_control/utils/generate_report/generate_html_report.py index 94e532e5..6238d64a 100644 --- a/je_auto_control/utils/generate_report/generate_html_report.py +++ b/je_auto_control/utils/generate_report/generate_html_report.py @@ -1,3 +1,4 @@ +import html from threading import Lock from je_auto_control.utils.exception.exception_tags import html_generate_no_data_tag_error_message @@ -107,14 +108,18 @@ def make_html_table(event_str: str, record_data: dict, table_head: str) -> str: :param table_head: 表頭樣式 (成功/失敗) Table head style :return: 更新後的 HTML 字串 Updated HTML string """ + # Escape recorded values — a param/exception may contain <, >, & (e.g. an + # AC_write of "" + out = make_html_table("", _record(payload), "event_table_head") + assert payload not in out + assert html.escape(payload) in out + assert "<script>" in out + + +def test_ampersand_and_angle_brackets_escaped(): + out = make_html_table("", _record("a & b < c"), "event_table_head") + assert "a & b < c" in out + assert "a & b < c" not in out + + +def test_exception_field_is_escaped(): + record = _record("ok") + record["program_exception"] = "boom" + out = make_html_table("", record, "failure_table_head") + assert "boom" not in out + assert "<b>boom</b>" in out + + +def test_none_values_render_as_literal_none(): + record = { + "function_name": None, "local_param": None, + "time": None, "program_exception": None, + } + out = make_html_table("", record, "event_table_head") + assert "None" in out diff --git a/test/unit_test/headless/test_r3_agent_llm_backend.py b/test/unit_test/headless/test_r3_agent_llm_backend.py new file mode 100644 index 00000000..7da5eaf7 --- /dev/null +++ b/test/unit_test/headless/test_r3_agent_llm_backend.py @@ -0,0 +1,60 @@ +"""Round-3 regression: the Anthropic *planner* LLM backend must catch +``anthropic.AnthropicError`` and honour the empty-string contract instead of +letting a 429/5xx/timeout escape ``complete``. + +The ``anthropic`` SDK is not installed in CI, so a fake module carrying an +``AnthropicError`` base class is injected for the duration of each test. +""" +import sys +import types + +from je_auto_control.utils.llm.backends.anthropic_backend import ( + AnthropicLLMBackend, +) + + +class _FakeAnthropicError(Exception): + """Stand-in for ``anthropic.AnthropicError`` (a bare Exception).""" + + +class _FakeRateLimitError(_FakeAnthropicError): + """Subclass, like the real ``anthropic.RateLimitError``.""" + + +def _install_fake_anthropic(monkeypatch): + module = types.ModuleType("anthropic") + module.AnthropicError = _FakeAnthropicError + monkeypatch.setitem(sys.modules, "anthropic", module) + + +def _backend(client): + backend = AnthropicLLMBackend.__new__(AnthropicLLMBackend) + backend.available = True + backend._client = client + return backend + + +def _client_raising(exc): + class _Messages: + def create(self, **kwargs): + raise exc + + return types.SimpleNamespace(messages=_Messages()) + + +def test_sdk_error_degrades_to_empty_string(monkeypatch): + _install_fake_anthropic(monkeypatch) + backend = _backend(_client_raising(_FakeRateLimitError("429 rate limited"))) + assert backend.complete("plan the next action") == "" + + +def test_successful_response_returns_joined_text(monkeypatch): + _install_fake_anthropic(monkeypatch) + block = types.SimpleNamespace(type="text", text="planned action") + + class _Messages: + def create(self, **kwargs): + return types.SimpleNamespace(content=[block]) + + client = types.SimpleNamespace(messages=_Messages()) + assert _backend(client).complete("plan the next action") == "planned action" diff --git a/test/unit_test/headless/test_r3_agent_loop_dispatch.py b/test/unit_test/headless/test_r3_agent_loop_dispatch.py new file mode 100644 index 00000000..2eb1f18c --- /dev/null +++ b/test/unit_test/headless/test_r3_agent_loop_dispatch.py @@ -0,0 +1,38 @@ +"""Round-3 regression: AgentLoop._dispatch_tool must record AutoControl* +exceptions and TypeErrors per-step (loop continues) instead of crashing the +whole run.""" +from je_auto_control.utils.agent import AgentLoop, FakeAgentBackend +from je_auto_control.utils.exception.exceptions import AutoControlMouseException + + +def _run_with_failing_runner(exc): + def runner(_name, _args): + raise exc + + backend = FakeAgentBackend([ + {"tool": "AC_click_mouse", "input": {"button": "left"}}, + {"stop": True, "message": "done"}, + ]) + return AgentLoop( + backend, tool_runner=runner, screenshot_fn=lambda: None, + ).run("goal") + + +def test_autocontrol_exception_recorded_and_loop_continues(): + result = _run_with_failing_runner(AutoControlMouseException("boom")) + failing = next(s for s in result.steps if s.tool == "AC_click_mouse") + assert failing.error is not None + assert "AutoControlMouseException" in failing.error + # The loop kept going to the stop decision rather than aborting the run. + assert result.succeeded is True + assert result.final_message == "done" + + +def test_type_error_from_hallucinated_kwarg_recorded_and_loop_continues(): + result = _run_with_failing_runner( + TypeError("unexpected keyword argument 'foo'"), + ) + failing = next(s for s in result.steps if s.tool == "AC_click_mouse") + assert failing.error is not None + assert "TypeError" in failing.error + assert result.succeeded is True diff --git a/test/unit_test/headless/test_r3_agent_openai_backend.py b/test/unit_test/headless/test_r3_agent_openai_backend.py new file mode 100644 index 00000000..52910855 --- /dev/null +++ b/test/unit_test/headless/test_r3_agent_openai_backend.py @@ -0,0 +1,73 @@ +"""Round-3 regressions for the OpenAI agent backend. + +Covers: + * ``parallel_tool_calls=False`` must be sent (the loop answers only the first + tool_call, so parallel calls would 400 the next request), + * a ``finish_reason == "length"`` truncation with no tool_call must be + surfaced as an error rather than a successful final answer. +""" +from types import SimpleNamespace + +import pytest + +from je_auto_control.utils.agent.backends.base import AgentBackendError +from je_auto_control.utils.agent.backends.openai import OpenAIAgentBackend + + +class _RecordingOpenAIClient: + """Captures chat.completions.create kwargs, returns a canned response.""" + + def __init__(self, response): + self.captured: dict = {} + outer = self + + class _Completions: + def create(self, **kwargs): + outer.captured = kwargs + return response + + class _Chat: + def __init__(self): + self.completions = _Completions() + + self.chat = _Chat() + + +def _tool_call(call_id, name="AC_click_mouse", arguments="{}"): + return SimpleNamespace( + id=call_id, function=SimpleNamespace(name=name, arguments=arguments), + ) + + +def _response(tool_calls=None, content=None, finish_reason="stop"): + message = SimpleNamespace(content=content, tool_calls=tool_calls) + choice = SimpleNamespace(message=message, finish_reason=finish_reason) + return SimpleNamespace(choices=[choice]) + + +def _backend(client): + return OpenAIAgentBackend(client=client, tools=[{"type": "function"}]) + + +def test_parallel_tool_calls_disabled_on_every_request(): + client = _RecordingOpenAIClient( + _response(tool_calls=[_tool_call("call_1")], finish_reason="tool_calls"), + ) + _backend(client).decide_next_action("goal", None, []) + assert client.captured.get("parallel_tool_calls") is False + + +def test_length_truncation_without_tool_call_raises(): + client = _RecordingOpenAIClient( + _response(content="partial", finish_reason="length"), + ) + with pytest.raises(AgentBackendError): + _backend(client).decide_next_action("goal", None, []) + + +def test_normal_completion_still_stops(): + client = _RecordingOpenAIClient( + _response(content="done", finish_reason="stop"), + ) + decision = _backend(client).decide_next_action("goal", None, []) + assert decision == {"stop": True, "message": "done"} diff --git a/test/unit_test/headless/test_r3_data_source_uri.py b/test/unit_test/headless/test_r3_data_source_uri.py new file mode 100644 index 00000000..63898daa --- /dev/null +++ b/test/unit_test/headless/test_r3_data_source_uri.py @@ -0,0 +1,37 @@ +"""Round-3 regression: data_source SQLite URI must percent-encode the path. + +The sibling bug in ``sql/sql_query.py`` was fixed by its own agent; this pins +the identical raw-f-string URI defect in ``data_source._load_sqlite`` — a path +containing ``%``/``#`` opened the wrong file (or none) despite the pre-check +passing. +""" +import sqlite3 + +import pytest + +from je_auto_control.utils.data_source.data_source import load_rows + + +def _make_db(path): + """Create a one-row SQLite DB at ``path`` (a normal, non-URI filename).""" + conn = sqlite3.connect(str(path)) + try: + conn.execute("CREATE TABLE t (name TEXT)") + conn.execute("INSERT INTO t (name) VALUES ('ok')") + conn.commit() + finally: + conn.close() + + +@pytest.mark.parametrize("filename", ["weird%41name.db", "has#hash.db"]) +def test_sqlite_source_opens_path_with_uri_special_chars(tmp_path, filename): + """A DB whose filename contains ``%``/``#`` loads its rows correctly.""" + db_path = tmp_path / filename + _make_db(db_path) + rows = load_rows( + {"kind": "sqlite", "path": str(db_path), "query": "SELECT name FROM t"}) + assert rows == [{"name": "ok"}] + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/test/unit_test/headless/test_r3_executor_containment.py b/test/unit_test/headless/test_r3_executor_containment.py new file mode 100644 index 00000000..708858c1 --- /dev/null +++ b/test/unit_test/headless/test_r3_executor_containment.py @@ -0,0 +1,145 @@ +"""Round-3 audit regressions for executor/flow-control containment. + +These pin the orchestrator-owned fixes that pair with the exception-hierarchy +reparent (every framework exception now subclasses ``AutoControlException``): + +* framework exceptions are contained (recorded) by the per-action boundary + instead of aborting the whole script; +* ``AC_try`` / ``AC_retry`` catch the whole family, not just + ``AutoControlActionException``; +* an empty flow body is a no-op rather than an ``AutoControlActionNullException``; +* ``AC_shell_to_var`` timeouts are contained; +* ``AC_expect_poll`` treats a failed polled action as "not yet satisfied"; +* ``execute_action_with_vars`` defers nested bodies (runtime ``${item}`` works); +* ``AC_parallel`` branches see custom commands / macros; +* byte-identical repeated actions keep separate record entries. + +No real mouse/keyboard/screen is driven — every command is a stub. +""" +import subprocess + +import pytest + +from je_auto_control.utils.executor.action_executor import ( + Executor, add_command_to_executor, execute_action_with_vars, executor, +) +from je_auto_control.utils.exception.exceptions import ( + AutoControlAssertionException, AutoControlMouseException, + ImageNotFoundException, +) + + +def test_framework_exception_is_contained_not_raised(): + """raise_on_error=False must record a framework error, not let it escape.""" + engine = Executor() + engine.event_dict["AC_r3_boom"] = _raiser(ImageNotFoundException("missing")) + record = engine.execute_action([["AC_r3_boom"]], _validated=True) + assert any("ImageNotFoundException" in str(v) for v in record.values()) + + +def test_ac_try_catches_framework_exception(): + """AC_try's catch branch runs when the body raises a framework error.""" + engine = Executor() + caught = {"ran": False} + engine.event_dict["AC_r3_boom"] = _raiser( + AutoControlAssertionException("assert failed")) + engine.event_dict["AC_r3_recover"] = lambda: caught.__setitem__("ran", True) + engine.execute_action( + [["AC_try", {"body": [["AC_r3_boom"]], + "catch": [["AC_r3_recover"]]}]], _validated=True) + assert caught["ran"] is True + + +def test_empty_loop_body_is_noop(): + """AC_loop with an empty body completes its iterations without error.""" + engine = Executor() + record = engine.execute_action( + [["AC_loop", {"times": 3, "body": []}]], _validated=True) + assert next(iter(record.values())) == 3 + + +def test_shell_to_var_timeout_is_contained(monkeypatch): + """A shell timeout is converted to a contained framework error.""" + def _timeout(*_args, **kwargs): + raise subprocess.TimeoutExpired(cmd="x", timeout=kwargs.get("timeout", 1)) # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit # reason: raising the TimeoutExpired exception class, not spawning a subprocess + + monkeypatch.setattr(subprocess, "run", _timeout) + engine = Executor() + record = engine.execute_action( + [["AC_shell_to_var", {"command": "sleep 9", "timeout": 1}]], + _validated=True) + assert any("timed out" in str(v) for v in record.values()) + + +def test_expect_poll_failed_action_is_not_success(): + """A polled action that always fails must not be reported as ok=True.""" + executor.event_dict["AC_r3_pollboom"] = _raiser( + AutoControlMouseException("nope")) + try: + record = executor.execute_action( + [["AC_expect_poll", {"action": ["AC_r3_pollboom"], + "timeout_s": 0.2, "interval_s": 0.05}]], + _validated=True) + result = next(iter(record.values())) + assert result["ok"] is False + finally: + executor.event_dict.pop("AC_r3_pollboom", None) + + +def test_execute_action_with_vars_defers_loop_body(): + """Runtime ${item} inside a deferred body resolves per-iteration. + + The eager pre-pass previously resolved ${item} against the seed mapping — + where it does not exist — and raised before execution. + """ + execute_action_with_vars( + [["AC_for_each", {"items": ["a", "b"], "as": "item", + "body": [["AC_set_var", + {"name": "r3_last", "value": "${item}"}]]}]], + {"seed_only": 1}) + assert executor.variables.get_value("r3_last") == "b" + + +def test_parallel_branch_sees_custom_command(): + """A command added via add_command_to_executor works inside AC_parallel.""" + ran = {"flag": False} + add_command_to_executor( + {"AC_r3_custom": lambda: ran.__setitem__("flag", True)}) + try: + executor.execute_action( + [["AC_parallel", {"branches": [[["AC_r3_custom"]]]}]], + _validated=True) + assert ran["flag"] is True + finally: + executor.event_dict.pop("AC_r3_custom", None) + + +def test_duplicate_actions_recorded_separately(): + """Byte-identical actions keep separate record entries (no overwrite).""" + engine = Executor() + calls = {"n": 0} + + def _sometimes(): + calls["n"] += 1 + if calls["n"] == 1: + raise AutoControlMouseException("first fails") + return "ok" + + engine.event_dict["AC_r3_dup"] = _sometimes + record = engine.execute_action( + [["AC_r3_dup"], ["AC_r3_dup"]], _validated=True) + assert len(record) == 2 + values = [str(v) for v in record.values()] + assert any("AutoControlMouseException" in v for v in values) + assert any(v == "ok" for v in values) + + +def _raiser(error): + """Return a zero-arg callable that raises ``error`` when invoked.""" + def _raise(): + raise error + return _raise + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/test/unit_test/headless/test_r3_gui_main_window.py b/test/unit_test/headless/test_r3_gui_main_window.py new file mode 100644 index 00000000..d6624b13 --- /dev/null +++ b/test/unit_test/headless/test_r3_gui_main_window.py @@ -0,0 +1,55 @@ +"""Round-3 GUI audit regression: applying the font must not wipe the theme. + +``_apply_font_pt`` used to call ``setStyleSheet("font-size: ...")`` which +*replaces* the widget stylesheet, discarding the qt_material theme that +``apply_stylesheet`` had just installed (finding 5). The font rule must now be +merged on top of the captured theme stylesheet instead. +""" +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6.QtWidgets") +# main_window imports qt_material (the theme); the headless CI job installs +# PySide6 but not the GUI theme extra, so skip cleanly there rather than erroring +# out collection for the whole suite. +pytest.importorskip("qt_material") + +from PySide6.QtWidgets import QApplication, QMainWindow # noqa: E402 + +from je_auto_control.gui.main_window import AutoControlGUIUI # noqa: E402 + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def test_apply_font_pt_preserves_theme_stylesheet(qapp): + # A bare QMainWindow stands in for the real window so the whole tab set is + # not constructed; _apply_font_pt only touches _theme_stylesheet, + # _detect_auto_font_pt (skipped for pt > 0) and setStyleSheet. + window = QMainWindow() + window._theme_stylesheet = "QWidget { color: rgb(1, 2, 3); }" + try: + AutoControlGUIUI._apply_font_pt(window, 14) + sheet = window.styleSheet() + assert "color: rgb(1, 2, 3)" in sheet # theme kept + assert "font-size: 14pt" in sheet # font applied + finally: + window.deleteLater() + + +def test_apply_font_pt_keeps_theme_across_size_changes(qapp): + window = QMainWindow() + window._theme_stylesheet = "QPushButton { background: rgb(9, 9, 9); }" + try: + AutoControlGUIUI._apply_font_pt(window, 12) + AutoControlGUIUI._apply_font_pt(window, 20) # simulate a text-size change + sheet = window.styleSheet() + assert "background: rgb(9, 9, 9)" in sheet # theme survives the change + assert "font-size: 20pt" in sheet + assert "font-size: 12pt" not in sheet # old rule fully replaced + finally: + window.deleteLater() diff --git a/test/unit_test/headless/test_r3_gui_script_builder.py b/test/unit_test/headless/test_r3_gui_script_builder.py new file mode 100644 index 00000000..d9d2d276 --- /dev/null +++ b/test/unit_test/headless/test_r3_gui_script_builder.py @@ -0,0 +1,143 @@ +"""Round-3 GUI audit regressions for the Script Builder model, tree and form. + +Covers: +* drag-drop no longer silently desyncs the Step model (finding 1); +* ``Step`` uses identity equality so duplicate steps are edited correctly + (finding 2); +* ``actions_to_steps`` unwraps the ``auto_control`` mapping and rejects + shapes it cannot represent instead of fabricating garbage (finding 3); +* ``_commit_field`` survives an unparseable value in one field instead of + aborting/freezing every later edit (finding 4). +""" +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6.QtWidgets") + +from PySide6.QtWidgets import QAbstractItemView, QApplication # noqa: E402 + +from je_auto_control.gui.script_builder.command_schema import ( # noqa: E402 + COMMAND_SPECS, +) +from je_auto_control.gui.script_builder.step_form_view import ( # noqa: E402 + StepFormView, +) +from je_auto_control.gui.script_builder.step_list_view import ( # noqa: E402 + StepTreeView, +) +from je_auto_control.gui.script_builder.step_model import ( # noqa: E402 + Step, action_to_step, actions_to_steps, steps_to_actions, +) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def _command_with_body_key(): + for command, spec in COMMAND_SPECS.items(): + if spec.body_keys: + return command, spec.body_keys[0] + return None, None + + +# --- Finding 2: identity equality ------------------------------------------ + +def test_step_uses_identity_equality(): + a = Step(command="AC_ok") + b = Step(command="AC_ok") # structurally identical, distinct object + assert a == a # NOSONAR python:S1764 # reason: verifies identity-equality reflexivity (a is a) + assert a != b + steps = [a, b] + steps.remove(b) + assert steps == [a] # removed the selected object, not the first equal one + + +def test_duplicate_root_delete_targets_selected(qapp): + tree = StepTreeView() + first = Step(command="AC_ok") + second = Step(command="AC_ok") # duplicate + tree.load_steps([first, second]) + tree.setCurrentItem(tree.topLevelItem(1)) # select the second (duplicate) + tree.remove_selected() + remaining = tree.root_steps() + assert len(remaining) == 1 + assert remaining[0] is first # the first survived, not a value match + + +# --- Finding 1: drag-drop disabled + nested delete stays in sync ----------- + +def test_item_drag_drop_is_disabled(qapp): + tree = StepTreeView() + # InternalMove reorders items without touching the model; it must be off. + assert tree.dragDropMode() == QAbstractItemView.DragDropMode.NoDragDrop + + +def test_nested_duplicate_delete_syncs_model(qapp): + command, body_key = _command_with_body_key() + if command is None: + pytest.skip("no flow-control command with body_keys in the schema") + child_a = Step(command="AC_ok") + child_b = Step(command="AC_ok") # duplicate of child_a + parent = Step(command=command, bodies={body_key: [child_a, child_b]}) + tree = StepTreeView() + tree.load_steps([parent]) + parent_item = tree.topLevelItem(0) + body_item = parent_item.child(0) + tree.setCurrentItem(body_item.child(1)) # the duplicate nested child + tree.remove_selected() # must not raise AttributeError + assert parent.bodies[body_key] == [child_a] + assert parent.bodies[body_key][0] is child_a + + +# --- Finding 3: actions_to_steps input shapes ------------------------------ + +def test_actions_to_steps_unwraps_auto_control_mapping(): + steps = actions_to_steps({"auto_control": [["AC_ok"]]}) + # Before the fix a dict was iterated as keys, so "auto_control" became a + # fabricated command "a". + assert [s.command for s in steps] == ["AC_ok"] + + +def test_actions_to_steps_round_trips_plain_list(): + steps = actions_to_steps([["AC_ok"]]) + assert steps_to_actions(steps) == [["AC_ok"]] + + +def test_actions_to_steps_rejects_dict_entry(): + with pytest.raises(ValueError): + actions_to_steps([{"foo": "bar"}]) + + +def test_actions_to_steps_rejects_string_action(): + with pytest.raises(ValueError): + action_to_step("auto_control") # used to become command "a" + + +def test_actions_to_steps_rejects_non_list(): + with pytest.raises(ValueError): + actions_to_steps(42) + + +def test_actions_to_steps_rejects_mapping_without_key(): + with pytest.raises(ValueError): + actions_to_steps({"not_auto_control": []}) + + +# --- Finding 4: commit survives an unparseable field ----------------------- + +def test_commit_survives_unparseable_int_field(qapp): + # AC_human_type has a STRING field (text) and an optional INT field (seed). + step = action_to_step(["AC_human_type", {"text": "hi", "seed": 5}]) + view = StepFormView() + view.load_step(step) + # A loaded float in an int field (setText bypasses the validator), exactly + # like a JSON "100.0" landing in an int editor. + view._editors["seed"].setText("100.0") + # Editing another field fires _commit_field; it must not abort on the bad + # int, so the string edit is persisted. + view._editors["text"].setText("changed") + assert step.params["text"] == "changed" diff --git a/test/unit_test/headless/test_r3_gui_slot_exceptions.py b/test/unit_test/headless/test_r3_gui_slot_exceptions.py new file mode 100644 index 00000000..830403c7 --- /dev/null +++ b/test/unit_test/headless/test_r3_gui_slot_exceptions.py @@ -0,0 +1,160 @@ +"""Round-3 GUI audit regressions: Actions-menu slots must contain framework +exceptions instead of letting them escape into the Qt event loop. + +* builder_tab / main_widget slots caught only builtin exception types while + their callees raise ``AutoControl*Exception`` (finding 6); +* the auto-click ``_do_click`` slot skipped ``timer.stop()`` on a throwing + backend, so the QTimer fired forever (finding 7); +* ``TestSuiteTab._on_load_file`` read a file with no error handling, so a + non-UTF-8 file escaped the slot (finding 10). + +Each slot is exercised on a lightweight stub ``self`` with the callee +monkeypatched to fail, so no full Qt widget tree is constructed. +""" +import os +import types + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6.QtWidgets") + +from je_auto_control.utils.exception.exceptions import ( # noqa: E402 + AutoControlExecuteActionException, AutoControlHTMLException, + AutoControlMouseException, +) + + +def _raiser(exc): + def _fn(*_args, **_kwargs): + raise exc + return _fn + + +# --- Finding 6: builder_tab._on_run ---------------------------------------- + +def test_builder_run_slot_surfaces_autocontrol_exception(monkeypatch): + import je_auto_control.gui.script_builder.builder_tab as bt + warned = {} + monkeypatch.setattr(bt.QMessageBox, "warning", + lambda *a, **k: warned.setdefault("hit", True)) + monkeypatch.setattr( + bt, "execute_action", + _raiser(AutoControlExecuteActionException("unregistered command")), + ) + + tree = types.SimpleNamespace(root_steps=lambda: [bt.Step(command="AC_ok")]) + result = types.SimpleNamespace(setPlainText=lambda _s: None) + stub = types.SimpleNamespace(_tree=tree, _result=result) + + bt.ScriptBuilderTab._on_run(stub) # must not raise + assert warned.get("hit") + + +# --- Finding 6: main_widget record / script slots -------------------------- + +def test_playback_record_slot_surfaces_autocontrol_exception(monkeypatch): + import je_auto_control.gui.main_widget as mw + warned = {} + monkeypatch.setattr(mw.QMessageBox, "warning", + lambda *a, **k: warned.setdefault("hit", True)) + monkeypatch.setattr( + mw, "execute_action", + _raiser(AutoControlExecuteActionException("boom")), + ) + stub = types.SimpleNamespace(_record_data=[["AC_ok"]]) + + mw.AutoControlGUIWidget._playback_record(stub) # must not raise + assert warned.get("hit") + + +def test_execute_script_slot_surfaces_autocontrol_exception(monkeypatch): + import je_auto_control.gui.main_widget as mw + captured = {} + monkeypatch.setattr(mw, "read_action_json", lambda _p: [["AC_ok"]]) + monkeypatch.setattr( + mw, "execute_action", + _raiser(AutoControlExecuteActionException("boom")), + ) + editor = types.SimpleNamespace(text=lambda: "some.json") + result = types.SimpleNamespace(setText=lambda s: captured.__setitem__("t", s)) + stub = types.SimpleNamespace(script_path_input=editor, + script_result_text=result) + + mw.AutoControlGUIWidget._execute_script(stub) # must not raise + assert captured.get("t", "").startswith("Error") + + +# --- Finding 6: report tab, zero records ----------------------------------- + +def test_report_gen_html_slot_surfaces_autocontrol_exception(): + from je_auto_control.gui._report_tab import ReportTabMixin + from je_auto_control.utils.test_record.record_test_class import ( + test_record_instance, + ) + test_record_instance.test_record_list.clear() # zero records -> raises + captured = {} + stub = types.SimpleNamespace( + report_name_input=types.SimpleNamespace(text=lambda: "rpt"), + report_result_text=types.SimpleNamespace( + setText=lambda s: captured.__setitem__("t", s), + ), + ) + + ReportTabMixin._gen_html(stub) # must not raise AutoControlHTMLException + assert captured.get("t", "").startswith("Error") + + +def test_report_generate_html_report_really_raises_on_zero_records(): + # Guards the assumption behind the slot test above. + from je_auto_control.utils.generate_report.generate_html_report import ( + generate_html_report, + ) + from je_auto_control.utils.test_record.record_test_class import ( + test_record_instance, + ) + test_record_instance.test_record_list.clear() + with pytest.raises(AutoControlHTMLException): + generate_html_report("unused") + + +# --- Finding 7: auto-click timer stops on a throwing backend --------------- + +def test_do_click_stops_timer_on_backend_exception(monkeypatch): + import je_auto_control.gui._auto_click_tab as ac + monkeypatch.setattr(ac.QMessageBox, "warning", lambda *a, **k: None) + monkeypatch.setattr( + ac, "click_mouse", + _raiser(AutoControlMouseException("no backend")), + ) + stops = {"n": 0} + stub = types.SimpleNamespace( + click_type_combo=types.SimpleNamespace(currentIndex=lambda: 0), + mouse_radio=types.SimpleNamespace(isChecked=lambda: True), + mouse_button_combo=types.SimpleNamespace(currentText=lambda: "mouse_left"), + cursor_x_input=types.SimpleNamespace(text=lambda: "0"), + cursor_y_input=types.SimpleNamespace(text=lambda: "0"), + timer=types.SimpleNamespace(stop=lambda: stops.__setitem__("n", stops["n"] + 1)), + ) + + ac.AutoClickTabMixin._do_click(stub) # must not raise + assert stops["n"] == 1 # the failing auto-click QTimer was stopped + + +# --- Finding 10: suite load-file tolerates a bad file ---------------------- + +def test_suite_load_file_handles_non_utf8(monkeypatch, tmp_path): + import je_auto_control.gui.test_suite_tab as ts + bad = tmp_path / "bad.json" + bad.write_bytes(b"\xff\xfe\x00 not valid utf-8 \xff") + monkeypatch.setattr(ts.QFileDialog, "getOpenFileName", + lambda *a, **k: (str(bad), "")) + captured = {} + stub = types.SimpleNamespace( + _spec=types.SimpleNamespace(setPlainText=lambda _s: captured.__setitem__("spec", True)), + _summary=types.SimpleNamespace(setText=lambda s: captured.__setitem__("summary", s)), + ) + + ts.TestSuiteTab._on_load_file(stub) # must not raise UnicodeDecodeError + assert "summary" in captured # error surfaced on the summary label + assert "spec" not in captured # the unreadable content was not shown diff --git a/test/unit_test/headless/test_r3_gui_thread_marshal.py b/test/unit_test/headless/test_r3_gui_thread_marshal.py new file mode 100644 index 00000000..6e1a1f29 --- /dev/null +++ b/test/unit_test/headless/test_r3_gui_thread_marshal.py @@ -0,0 +1,159 @@ +"""Round-3 GUI audit regressions for worker-thread -> GUI-thread hand-off. + +* ``QTimer.singleShot`` fired from a non-Qt thread never runs (no event loop + there); the LAN browser, presence roster and WebRTC file-received callbacks + must marshal via a Qt Signal instead (finding 8); +* the admin-console thumbnail poll must ``deleteLater`` its QThread/worker each + tick instead of leaking one per interval (finding 9). + +Each test drives the real method from a background ``threading.Thread`` and +pumps the GUI event loop, so a queued signal is required for the effect to +appear (a thread-affine ``singleShot`` would not fire). +""" +import os +import threading +import time + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("PySide6.QtWidgets") + +import shiboken6 # noqa: E402 +from PySide6.QtCore import QEvent, QObject, QThread # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + + +# Constructing the WebRTC panel / admin-console QThread teardown inside the +# SHARED pytest process natively aborts (SIGABRT/0xC0000409) under the offscreen +# Qt platform on CI — accumulated Qt state across GUI tests corrupts on teardown. +# The product paths these cover are exercised by the full-widget build in +# test_actions_menu_gui, which is deliberately run in an isolated subprocess for +# exactly this reason. These need the same subprocess isolation before they can +# run in-process; skip until then rather than crash the whole suite. +_OFFSCREEN_SHARED_PROC_ABORT = ( + "worker->GUI teardown aborts the shared pytest process under offscreen Qt; " + "needs subprocess isolation (see test_actions_menu_gui)" +) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def _pump_until(qapp, predicate, timeout=3.0): + deadline = time.time() + timeout + while not predicate() and time.time() < deadline: + qapp.processEvents() + time.sleep(0.005) + return predicate() + + +def _run_off_thread(target): + thread = threading.Thread(target=target) + thread.start() + thread.join(3.0) + + +# --- Finding 8: LAN browse dialog ------------------------------------------ + +def test_lan_browse_services_marshaled_to_gui(qapp, monkeypatch): + import je_auto_control.utils.remote_desktop.lan_discovery as lan + # Keep the dialog hermetic: no real mDNS browser. + monkeypatch.setattr(lan, "is_discovery_available", lambda: False) + from je_auto_control.gui.remote_desktop.webrtc_dialogs import LanBrowseDialog + + dialog = LanBrowseDialog() + try: + assert dialog._table.rowCount() == 0 + services = {"h1": {"host_id": "h1", "ip": "10.0.0.1", + "signaling_url": "wss://x", "name": "Host One"}} + _run_off_thread(lambda: dialog._update_services(services)) + assert _pump_until(qapp, lambda: dialog._table.rowCount() == 1) + finally: + dialog.close() + dialog.deleteLater() + + +# --- Finding 8: presence roster -------------------------------------------- + +def test_presence_registry_event_marshaled_to_gui(qapp): + from je_auto_control.gui.presence_tab import PresenceTab + + tab = PresenceTab() + try: + tab._timer.stop() # only the marshaled signal may refresh the roster + tab._status.setText("SENTINEL") + _run_off_thread(lambda: tab._on_registry_event("viewer-x", None)) + assert _pump_until(qapp, lambda: tab._status.text() != "SENTINEL") + finally: + tab._timer.stop() + tab._registry.remove_listener(tab._on_registry_event) + tab.deleteLater() + + +# --- Finding 8: WebRTC file-received callback ------------------------------ + +@pytest.mark.skip(reason=_OFFSCREEN_SHARED_PROC_ABORT) +def test_panel_signals_expose_file_received(): + from je_auto_control.gui.remote_desktop.webrtc_panel import _PanelSignals + assert hasattr(_PanelSignals(), "file_received") + + +@pytest.mark.skip(reason=_OFFSCREEN_SHARED_PROC_ABORT) +def test_webrtc_received_file_marshaled_to_gui(qapp): + import types + from je_auto_control.gui.remote_desktop.webrtc_panel import ( + _PanelSignals, _WebRTCViewerPanel, + ) + + signals = _PanelSignals() + + class _Receiver(QObject): + def __init__(self): + super().__init__() + self.got = None + + def on_file(self, path): + self.got = path + + recv = _Receiver() + signals.file_received.connect(recv.on_file) + stub = types.SimpleNamespace(_signals=signals) + + _run_off_thread(lambda: _WebRTCViewerPanel._on_received_file(stub, "file-123")) + assert _pump_until(qapp, lambda: recv.got == "file-123") + + +# --- Finding 9: thumbnail poll thread is reaped on finish ------------------ + +@pytest.mark.skip(reason=_OFFSCREEN_SHARED_PROC_ABORT) +def test_thumbnail_poll_thread_is_reaped(qapp, monkeypatch, tmp_path): + import je_auto_control.gui.admin_console_tab as admin_mod + from je_auto_control.utils.admin.admin_client import AdminConsoleClient + + client = AdminConsoleClient(persist_path=tmp_path / "hosts.json") + monkeypatch.setattr(admin_mod, "default_admin_console", lambda: client) + # Don't run a real background thread: the reaping wiring is what matters, + # and this keeps the test deterministic (no timing, no dangling threads). + monkeypatch.setattr(admin_mod.QThread, "start", lambda self: None) + + tab = admin_mod.AdminConsoleTab() + try: + tab._thumb_timer.stop() + tab._refresh_thumbnails() + thread = tab._thumb_thread + assert thread is not None + assert thread in tab.findChildren(QThread) + + thread.finished.emit() # simulate the QThread finishing + assert tab._thumb_thread is None # _on_thumb_thread_done ran + # Flush the deferred deletions the finished signal scheduled. + qapp.sendPostedEvents(None, QEvent.Type.DeferredDelete.value) + # Without the deleteLater wiring the QThread would linger as a child of + # the tab, accumulating one per poll tick. + assert not shiboken6.Shiboken.isValid(thread) + assert tab.findChildren(QThread) == [] + finally: + tab.deleteLater() diff --git a/test/unit_test/headless/test_r3_mcp_connection_isolation.py b/test/unit_test/headless/test_r3_mcp_connection_isolation.py new file mode 100644 index 00000000..1f18d415 --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_connection_isolation.py @@ -0,0 +1,109 @@ +"""Round-3 regression: per-connection isolation of calls and capabilities. + +Over HTTP each peer runs on its own thread but shared server-global state +made two clients that both use JSON-RPC id 1 clobber each other's active +call context (cross-cancel), and one client's ``initialize`` overwrote +another's advertised capabilities (breaking the destructive-confirmation +gate). ``connection_scope(connection_id=...)`` now scopes both. These tests +drive the dispatcher via connection_scope directly — no sockets needed. +""" +import json +import threading +from typing import Any, Dict, Optional + +from je_auto_control.utils.mcp_server.server import MCPServer +from je_auto_control.utils.mcp_server.tools import MCPTool + + +def _toolcall(name: str, msg_id: int) -> str: + return json.dumps({ + "jsonrpc": "2.0", "id": msg_id, "method": "tools/call", + "params": {"name": name, "arguments": {}}, + }) + + +def _cancel(request_id: int) -> str: + return json.dumps({ + "jsonrpc": "2.0", "method": "notifications/cancelled", + "params": {"requestId": request_id}, + }) + + +def _initialize(caps: Dict[str, Any]) -> str: + return json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"capabilities": caps, "protocolVersion": "2025-06-18"}, + }) + + +class _Gate: + def __init__(self) -> None: + self.started = threading.Event() + self.proceed = threading.Event() + self.saw_cancelled: Optional[bool] = None + + +def _make_tool(name: str, gate: _Gate) -> MCPTool: + def handler(ctx): + gate.started.set() + gate.proceed.wait(timeout=3.0) + gate.saw_cancelled = ctx.cancelled + return "done" + return MCPTool( + name=name, description=name, + input_schema={"type": "object", "properties": {}}, + handler=handler, + ) + + +def _run_in_scope(server: MCPServer, conn_id: str, line: str) -> None: + with server.connection_scope(connection_id=conn_id): + server.handle_line(line) + + +def test_same_msg_id_on_two_connections_do_not_clobber_or_cross_cancel(): + gate_a, gate_b = _Gate(), _Gate() + server = MCPServer(tools=[_make_tool("a", gate_a), _make_tool("b", gate_b)]) + + thread_a = threading.Thread( + target=_run_in_scope, args=(server, "A", _toolcall("a", 1))) + thread_b = threading.Thread( + target=_run_in_scope, args=(server, "B", _toolcall("b", 1))) + thread_a.start() + thread_b.start() + assert gate_a.started.wait(timeout=2.0) + assert gate_b.started.wait(timeout=2.0) + + # Two in-flight calls sharing msg id 1 must occupy two distinct slots. + with server._calls_lock: + assert len(server._active_calls) == 2 + + # Cancelling connection A's call must not touch connection B's. + with server.connection_scope(connection_id="A"): + server.handle_line(_cancel(1)) + gate_a.proceed.set() + gate_b.proceed.set() + thread_a.join(timeout=3.0) + thread_b.join(timeout=3.0) + + assert gate_a.saw_cancelled is True + assert gate_b.saw_cancelled is False + + +def test_client_capabilities_are_scoped_per_connection(): + server = MCPServer(tools=[]) + _run_in_scope(server, "A", _initialize({"elicitation": {}})) + _run_in_scope(server, "B", _initialize({})) + + with server.connection_scope(connection_id="A"): + assert "elicitation" in server._client_capabilities + with server.connection_scope(connection_id="B"): + assert "elicitation" not in server._client_capabilities + + +def test_forget_connection_drops_per_connection_capabilities(): + server = MCPServer(tools=[]) + _run_in_scope(server, "A", _initialize({"elicitation": {}})) + server.forget_connection("A") + with server.connection_scope(connection_id="A"): + assert server._client_capabilities == {} diff --git a/test/unit_test/headless/test_r3_mcp_http_timeout.py b/test/unit_test/headless/test_r3_mcp_http_timeout.py new file mode 100644 index 00000000..d88f5cd5 --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_http_timeout.py @@ -0,0 +1,103 @@ +"""Round-3 regression: the MCP HTTP transport must not be wedged by a peer. + +The TLS handshake ran inside ``accept()`` on the single accept thread with no +bound, so one silent client blocked every other peer; and the request handler +had no read timeout, so a Content-Length underrun pinned a worker forever. +The handshake is now bounded in ``get_request`` and the handler declares a +finite ``timeout``. +""" +import datetime +import http.client +import ipaddress +import socket +import ssl +import time +from pathlib import Path + +import pytest + +from je_auto_control.utils.mcp_server import http_transport +from je_auto_control.utils.mcp_server.http_transport import ( + _MCPHttpHandler, start_mcp_http_server, +) + +cryptography = pytest.importorskip("cryptography") + +from cryptography import x509 # noqa: E402 +from cryptography.hazmat.primitives import hashes, serialization # noqa: E402 +from cryptography.hazmat.primitives.asymmetric import rsa # noqa: E402 +from cryptography.x509.oid import NameOID # noqa: E402 + + +def test_handler_declares_a_finite_request_timeout(): + # Before the fix the handler inherited BaseHTTPRequestHandler.timeout=None + # (no bound); a positive finite value is what closes a stalled body read. + assert isinstance(_MCPHttpHandler.timeout, (int, float)) + assert _MCPHttpHandler.timeout > 0 + + +def _server_ssl_context(tmp_path: Path) -> ssl.SSLContext: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "mcp-test")]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name).issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(minutes=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName([ + x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), + ]), critical=False) + .sign(private_key=key, algorithm=hashes.SHA256()) + ) + cert_path = tmp_path / "cert.pem" + key_path = tmp_path / "key.pem" + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes(key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + )) + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) # NOSONAR python:S4423 # reason: loopback test server; PROTOCOL_TLS_SERVER negotiates modern TLS + ctx.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) + return ctx + + +def _insecure_client_context() -> ssl.SSLContext: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # NOSONAR S5527 # loopback test + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE # NOSONAR S4830 # loopback self-signed test + return ctx + + +def _post_initialize(host: str, port: int, ctx: ssl.SSLContext) -> int: + body = ('{"jsonrpc":"2.0","id":1,"method":"initialize",' + '"params":{"protocolVersion":"2025-06-18","capabilities":{}}}') + conn = http.client.HTTPSConnection(host, port, context=ctx, timeout=6.0) + try: + conn.request("POST", "/mcp", body=body, + headers={"Content-Type": "application/json"}) + return conn.getresponse().status + finally: + conn.close() + + +def test_silent_tls_client_does_not_wedge_the_accept_thread(tmp_path, + monkeypatch): + monkeypatch.setattr(http_transport, "_HANDSHAKE_TIMEOUT", 0.5) + server = start_mcp_http_server( + host="127.0.0.1", port=0, ssl_context=_server_ssl_context(tmp_path)) + host, port = server.address + silent = socket.create_connection((host, port), timeout=2.0) + try: + # Let the accept thread pick up the silent socket and enter the + # (now time-boxed) handshake. Without the bound this wedges forever. + time.sleep(0.3) + status = _post_initialize(host, port, _insecure_client_context()) + assert status == 200 + finally: + silent.close() + server.stop(timeout=2.0) diff --git a/test/unit_test/headless/test_r3_mcp_live_screen.py b/test/unit_test/headless/test_r3_mcp_live_screen.py new file mode 100644 index 00000000..7e627247 --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_live_screen.py @@ -0,0 +1,70 @@ +"""Round-3 regression (lower): live-screen and subscribe hardening. + +``LiveScreenProvider`` reused a single stop event and ``.clear()``-ed it, so a +quick unsubscribe -> subscribe could restart a second broadcast thread while +the first was still looping (double notifications). ``resources/subscribe`` +checked membership and subscribed under separate lock windows, a TOCTOU that +could create and leak a duplicate provider handle. The first test is a real +regression on the fresh-event fix; the second guards the subscribe contract. +""" +import json +from typing import Any, Callable, Dict, List, Optional + +from je_auto_control.utils.mcp_server.resources import ( + LiveScreenProvider, MCPResource, ResourceProvider, +) +from je_auto_control.utils.mcp_server.server import MCPServer + + +def test_resubscribe_uses_a_fresh_stop_event(): + provider = LiveScreenProvider(poll_seconds=1.0) + handle = provider.subscribe(provider.URI, lambda: None) + old_stop = provider._stop + provider.unsubscribe(provider.URI, handle) + # Emptying the subscriber set must tell the old thread to exit. + assert old_stop.is_set() + + second = provider.subscribe(provider.URI, lambda: None) + try: + # A reused-and-cleared event would leave the old thread running; the + # fix binds each thread to its own event instead. + assert provider._stop is not old_stop + assert not provider._stop.is_set() + finally: + provider.unsubscribe(provider.URI, second) + + +class _CountingProvider(ResourceProvider): + URI = "test://sub" + + def __init__(self) -> None: + self.subscribe_calls = 0 + + def list(self) -> List[MCPResource]: + return [MCPResource(uri=self.URI, name="sub")] + + def read(self, uri: str) -> Optional[Dict[str, Any]]: + return None + + def subscribe(self, uri: str, + on_update: Callable[[], None]) -> Optional[Any]: + if uri != self.URI: + return None + self.subscribe_calls += 1 + return object() + + +def _subscribe(server: MCPServer, uri: str) -> Dict[str, Any]: + return json.loads(server.handle_line(json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "resources/subscribe", + "params": {"uri": uri}, + }))) + + +def test_duplicate_subscribe_creates_a_single_provider_handle(): + provider = _CountingProvider() + server = MCPServer(tools=[], resource_provider=provider) + assert "error" not in _subscribe(server, provider.URI) + assert "error" not in _subscribe(server, provider.URI) + # Second subscribe for a live uri must be a no-op, not a new handle. + assert provider.subscribe_calls == 1 diff --git a/test/unit_test/headless/test_r3_mcp_malformed_dispatch.py b/test/unit_test/headless/test_r3_mcp_malformed_dispatch.py new file mode 100644 index 00000000..157e2744 --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_malformed_dispatch.py @@ -0,0 +1,56 @@ +"""Round-3 regression: one malformed message must not kill the server. + +``params`` was never validated to be an object (a list ``params`` reached +handlers that call ``params.get`` and raised ``AttributeError``), and an +inbound response with a non-hashable ``id`` raised ``TypeError`` when used +as a dict key. Neither was guarded, and the stdio read loop had no net, so a +single bad line terminated the whole server. These tests drive the +dispatcher and the stdio loop directly. +""" +import io +import json + +from je_auto_control.utils.mcp_server.server import MCPServer + + +def test_request_with_non_object_params_returns_invalid_params(): + server = MCPServer(tools=[]) + decoded = json.loads(server.handle_line(json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": [1], + }))) + assert decoded["error"]["code"] == -32602 + + +def test_notification_with_non_object_params_does_not_raise(): + server = MCPServer(tools=[]) + # A list-typed params on a notification path previously reached + # _cancel_active_call([...]).get -> AttributeError, uncaught in handle_line. + result = server.handle_line(json.dumps({ + "jsonrpc": "2.0", "method": "notifications/cancelled", "params": [1], + })) + assert result is None + + +def test_inbound_response_with_non_hashable_id_is_dropped(): + server = MCPServer(tools=[]) + # {"id": [1], "result": {}} is treated as a reply to a server request; + # the id is a dict key, so a list id raised TypeError before the guard. + result = server.handle_line(json.dumps({ + "jsonrpc": "2.0", "id": [1], "result": {}, + })) + assert result is None + + +def test_stdio_loop_survives_a_malformed_line(): + server = MCPServer(tools=[]) + lines = "\n".join([ + json.dumps({"jsonrpc": "2.0", "id": [1], "result": {}}), # bad id + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "ping"}), + ]) + "\n" + out = io.StringIO() + server.serve_stdio(stdin=io.StringIO(lines), stdout=out) + responses = [json.loads(line) for line in out.getvalue().splitlines() + if line.strip()] + # The ping after the malformed line proves the loop kept running. + ids = [msg.get("id") for msg in responses] + assert 2 in ids diff --git a/test/unit_test/headless/test_r3_mcp_plugin_reload.py b/test/unit_test/headless/test_r3_mcp_plugin_reload.py new file mode 100644 index 00000000..3cbd4ea7 --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_plugin_reload.py @@ -0,0 +1,66 @@ +"""Round-3 regression: plugin hot-reload must survive broken plugins. + +``_reload_file`` unregistered the file's existing tools *before* loading and +caught only ``(OSError, ImportError, SyntaxError)`` — a plugin whose module +body raised a runtime error (e.g. ``NameError``) or whose tool construction +failed killed the watcher thread and left the tools vanished. Separately, a +whitespace-only docstring made ``make_plugin_tool`` raise ``IndexError``. +""" +import time + +from je_auto_control.utils.mcp_server.plugin_watcher import PluginWatcher +from je_auto_control.utils.mcp_server.server import MCPServer +from je_auto_control.utils.mcp_server.tools.plugin_tools import make_plugin_tool + + +def _write(path, body): + import os + previous = path.stat().st_mtime if path.exists() else 0.0 + path.write_text(body, encoding="utf-8") + now = max(time.time(), previous + 1.0) + os.utime(path, (now, now)) + + +def test_broken_reload_keeps_previous_tools_and_watcher_alive(tmp_path): + plugin = tmp_path / "evolving.py" + _write(plugin, "def AC_ok():\n return 'v1'\n") + server = MCPServer(tools=[]) + watcher = PluginWatcher(server, str(tmp_path), poll_seconds=0.1) + watcher.poll_once() + assert "plugin_ac_ok" in server._tools + + # Overwrite with a module that raises at import time (NameError is not in + # the old narrow catch tuple). poll_once must not raise and must retain + # the previously-registered tool rather than dropping it. + _write(plugin, "undefined_symbol_at_module_level\n") + watcher.poll_once() + assert "plugin_ac_ok" in server._tools + + +def test_valid_reload_after_recovery_swaps_tools(tmp_path): + plugin = tmp_path / "recover.py" + _write(plugin, "def AC_thing():\n return 1\n") + server = MCPServer(tools=[]) + watcher = PluginWatcher(server, str(tmp_path), poll_seconds=0.1) + watcher.poll_once() + _write(plugin, "boom_undefined\n") # broken edit + watcher.poll_once() + _write(plugin, "def AC_thing():\n return 2\n") # fixed edit + watcher.poll_once() + assert server._tools["plugin_ac_thing"].handler() == 2 + + +def test_make_plugin_tool_handles_whitespace_only_docstring(): + def handler(): + return None + handler.__doc__ = " \n \t " + tool = make_plugin_tool("AC_ws", handler) + assert tool.description == "Plugin command 'AC_ws'." + + +def test_make_plugin_tool_uses_first_docstring_line(): + def handler(): + return None + handler.__doc__ = "First line summary.\nMore detail." + tool = make_plugin_tool("AC_doc", handler) + assert tool.description == "First line summary." diff --git a/test/unit_test/headless/test_r3_mcp_rate_limit.py b/test/unit_test/headless/test_r3_mcp_rate_limit.py new file mode 100644 index 00000000..220af20c --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_rate_limit.py @@ -0,0 +1,34 @@ +"""Round-3 regression: ac_rate_limit must honour changed rate/capacity. + +The handler used ``_RATE_LIMITERS.setdefault(name, TokenBucket(rate, +capacity))`` so the *second* call with the same name but a new rate or +capacity silently kept the original bucket. The fix rebuilds the bucket when +either parameter changes while reusing it when they do not. +""" +import uuid + +from je_auto_control.utils.mcp_server.tools._handlers import rate_limit + + +def _name() -> str: + return f"r3-{uuid.uuid4().hex}" + + +def test_changed_capacity_rebuilds_the_bucket(): + name = _name() + first = rate_limit(name, rate=1.0, capacity=1.0) + assert first["acquired"] is True # drains the single token + + # Same name, larger capacity: previously ignored (bucket still cap 1, so + # this second acquire would fail with ~0 tokens). Now it rebuilds. + second = rate_limit(name, rate=1.0, capacity=5.0) + assert second["acquired"] is True + assert second["tokens"] > 3.5 + + +def test_unchanged_params_reuse_the_same_bucket(): + name = _name() + first = rate_limit(name, rate=100.0, capacity=100.0) + second = rate_limit(name, rate=100.0, capacity=100.0) + # A reused bucket keeps draining; a rebuilt one would reset to full. + assert second["tokens"] < first["tokens"] diff --git a/test/unit_test/headless/test_r3_mcp_tool_error_containment.py b/test/unit_test/headless/test_r3_mcp_tool_error_containment.py new file mode 100644 index 00000000..b27c5c89 --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_tool_error_containment.py @@ -0,0 +1,60 @@ +"""Round-3 regression: failing tools must return an isError result. + +The tool-invocation catch tuple omitted the framework exception family +(``AutoControlException`` and its subclasses such as +``ImageNotFoundException``), ``subprocess.TimeoutExpired`` and +``sqlite3.Error``. A tool raising any of those escaped both containment +layers — over stdio the worker thread died and the client hung; over HTTP +the connection aborted. These tests drive the dispatcher directly. +""" +import json +import sqlite3 +import subprocess +from typing import Any, Dict + +import pytest + +from je_auto_control.utils.exception.exceptions import ImageNotFoundException +from je_auto_control.utils.mcp_server.server import MCPServer +from je_auto_control.utils.mcp_server.tools import MCPTool + + +def _tool(name: str, exc: Exception) -> MCPTool: + def handler(): + raise exc + return MCPTool( + name=name, description=name, + input_schema={"type": "object", "properties": {}}, + handler=handler, + ) + + +def _call(server: MCPServer, name: str) -> Dict[str, Any]: + raw = server.handle_line(json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": {}}, + })) + assert raw is not None + return json.loads(raw) + + +@pytest.mark.parametrize("exc", [ + ImageNotFoundException("not on screen"), + subprocess.TimeoutExpired(cmd="sleep", timeout=1.0), # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit.dangerous-subprocess-use-audit # reason: exception instance for parametrize, not a subprocess call + sqlite3.OperationalError("no such table"), +]) +def test_framework_and_external_errors_become_iserror_result(exc): + name = "boom" + server = MCPServer(tools=[_tool(name, exc)]) + decoded = _call(server, name) + # A contained failure is a *result* with isError, never a JSON-RPC error + # and never an escaped exception (which would have hung the transport). + assert "result" in decoded, decoded + assert decoded["result"]["isError"] is True + assert decoded["result"]["content"][0]["type"] == "text" + + +def test_error_response_does_not_leak_as_minus_32603(): + server = MCPServer(tools=[_tool("img", ImageNotFoundException("x"))]) + decoded = _call(server, "img") + assert "error" not in decoded diff --git a/test/unit_test/headless/test_r3_mcp_validation_union.py b/test/unit_test/headless/test_r3_mcp_validation_union.py new file mode 100644 index 00000000..525650a4 --- /dev/null +++ b/test/unit_test/headless/test_r3_mcp_validation_union.py @@ -0,0 +1,51 @@ +"""Round-3 regression: schema validator must accept union ``type`` lists. + +Several tools declare a JSON-Schema union such as ``{"type": ["string", +"array"]}``. The validator used ``_TYPE_CHECKS.get(expected)`` which raised +``TypeError: unhashable type: 'list'`` for a union, so every call to those +tools failed. These tests exercise the validator directly (no Qt, no +platform backends). +""" +import pytest + +from je_auto_control.utils.mcp_server.tools._validation import ( + validate_arguments, +) + + +def _union_schema(): + return { + "type": "object", + "properties": {"key": {"type": ["string", "array"]}}, + "required": ["key"], + } + + +@pytest.mark.parametrize("value", ["a string", ["a", "list"]]) +def test_union_type_accepts_any_listed_type(value): + assert validate_arguments(_union_schema(), {"key": value}) is None + + +def test_union_type_rejects_value_matching_no_listed_type(): + message = validate_arguments(_union_schema(), {"key": 5}) + assert message is not None + assert "expected one of" in message + assert "key" in message + + +def test_optional_union_property_when_absent_is_valid(): + schema = { + "type": "object", + "properties": {"params": {"type": ["array", "object"]}}, + } + assert validate_arguments(schema, {}) is None + + +def test_union_including_null_accepts_none(): + schema = { + "type": "object", + "properties": {"pages": {"type": ["integer", "array", "null"]}}, + } + assert validate_arguments(schema, {"pages": None}) is None + assert validate_arguments(schema, {"pages": 3}) is None + assert validate_arguments(schema, {"pages": "nope"}) is not None diff --git a/test/unit_test/headless/test_r3_net_acme_client.py b/test/unit_test/headless/test_r3_net_acme_client.py new file mode 100644 index 00000000..21a21891 --- /dev/null +++ b/test/unit_test/headless/test_r3_net_acme_client.py @@ -0,0 +1,45 @@ +"""Round-3 net audit: the ACME client retries a badNonce rejection. + +RFC 8555 §6.5 requires retrying a badNonce rejection once with the fresh +nonce the server just supplied. +""" +from je_auto_control.utils.acme_v2 import client as acme + + +def _bare_client(): + """An AcmeClient with only the attributes the flow under test touches.""" + client = acme.AcmeClient.__new__(acme.AcmeClient) + client._account_key = object() + client._kid = "kid" + client._nonce = None + client._directory = {"newNonce": "https://example/nonce"} + return client + + +# --- badNonce retry ------------------------------------------------------- + +def test_signed_post_retries_once_on_bad_nonce(monkeypatch): + client = _bare_client() + client._nonce = "nonce-1" + monkeypatch.setattr( + acme, "sign_compact", + lambda **kwargs: {"protected": "p", "payload": "l", "signature": "s"}) + + responses = [ + (400, {"type": "urn:ietf:params:acme:error:badNonce"}, + {"Replay-Nonce": "nonce-2"}), + (200, {"status": "valid"}, {"Replay-Nonce": "nonce-3"}), + ] + calls: list = [] + + def fake_http(method, url, *, body=None, content_type=None, accept=None): + calls.append((method, url)) + return responses[len(calls) - 1] + + monkeypatch.setattr(client, "_http", fake_http) + + status, parsed, _headers = client._signed_post("https://acct", {"x": 1}) + + assert status == 200 + assert parsed == {"status": "valid"} + assert len(calls) == 2 # retried once with the fresh nonce diff --git a/test/unit_test/headless/test_r3_net_admin_client.py b/test/unit_test/headless/test_r3_net_admin_client.py new file mode 100644 index 00000000..cf40c112 --- /dev/null +++ b/test/unit_test/headless/test_r3_net_admin_client.py @@ -0,0 +1,72 @@ +"""Round-3 net audit: the admin address book must persist safely. + +``_save`` snapshotted the host map under the lock but wrote the file outside +it, so a stale snapshot could clobber a concurrent add_host (a lost host). The +write itself was truncate-then-write, so a crash mid-write corrupted every +stored token. The fix writes under the lock and atomically (temp + os.replace). +""" +import json +import os +import threading + +from je_auto_control.utils.admin.admin_client import AdminConsoleClient + + +def test_save_writes_under_lock(tmp_path): + """The file write must happen while ``self._lock`` is held.""" + client = AdminConsoleClient(persist_path=tmp_path / "hosts.json") + observed: dict = {} + original = client._write_atomic + + def spy(payload): + # Non-reentrant Lock: a non-blocking acquire fails iff already held. + acquired = client._lock.acquire(blocking=False) + observed["held"] = not acquired + if acquired: + client._lock.release() + return original(payload) + + client._write_atomic = spy + client.add_host("x", "https://x", "tok") + + assert observed["held"] is True + + +def test_write_is_atomic_and_cleans_up_on_failure(tmp_path, monkeypatch): + """A failed replace must leave the prior file intact and drop the temp.""" + path = tmp_path / "hosts.json" + client = AdminConsoleClient(persist_path=path) + client.add_host("keep", "https://k", "tok-keep") + good = path.read_text(encoding="utf-8") + + def boom_replace(_src, _dst): + raise OSError("disk full") + + monkeypatch.setattr(os, "replace", boom_replace) + client.add_host("second", "https://s", "tok-2") # save fails atomically + + assert path.read_text(encoding="utf-8") == good # not truncated + data = json.loads(path.read_text(encoding="utf-8")) + assert {h["label"] for h in data["hosts"]} == {"keep"} + leftovers = [name for name in os.listdir(tmp_path) + if name.startswith(".admin_hosts_")] + assert leftovers == [] # temp file cleaned up + + +def test_concurrent_add_host_does_not_lose_hosts(tmp_path): + """The last save (always a save, under the lock) persists every host.""" + path = tmp_path / "hosts.json" + client = AdminConsoleClient(persist_path=path) + + def add(index: int) -> None: + client.add_host(f"host{index}", f"https://h{index}", f"tok{index}") + + threads = [threading.Thread(target=add, args=(i,)) for i in range(20)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + data = json.loads(path.read_text(encoding="utf-8")) + labels = {h["label"] for h in data["hosts"]} + assert labels == {f"host{i}" for i in range(20)} diff --git a/test/unit_test/headless/test_r3_net_background_loops.py b/test/unit_test/headless/test_r3_net_background_loops.py new file mode 100644 index 00000000..43e2cfee --- /dev/null +++ b/test/unit_test/headless/test_r3_net_background_loops.py @@ -0,0 +1,167 @@ +"""Round-3 net audit: background poll loops must survive per-tick failures. + +The scheduler, ACME renewal, screen observer, and popup watchdog each own a +single daemon thread. Any escaping exception is a poison pill that stops *all* +work the thread drives. These tests pin the containment at each loop's edge. +""" +import sqlite3 +import time + +import je_auto_control.wrapper.auto_control_image as image_mod +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, AutoControlCantFindKeyException, + AutoControlKeyboardException, AutoControlScreenException, + ImageNotFoundException, +) +from je_auto_control.utils.observer.observer import ( + ScreenObserver, image_predicate, +) +from je_auto_control.utils.scheduler import scheduler as sch +from je_auto_control.utils.tls_acme import renewal +from je_auto_control.utils.watchdog.popup_watchdog import ( + PopupWatchdog, WatchdogRule, +) + + +# --- finding 4: scheduler -------------------------------------------------- + +def test_tick_survives_history_db_error(monkeypatch): + """A sqlite3.Error from start_run (outside _fire's own except) is contained.""" + class BoomHistory: + def start_run(self, *args, **kwargs): + raise sqlite3.OperationalError("database is locked") + + def finish_run(self, *args, **kwargs): + return True + + monkeypatch.setattr(sch, "default_history_store", BoomHistory()) + monkeypatch.setattr(sch, "capture_error_snapshot", lambda run_id: None) + monkeypatch.setattr(sch, "read_action_json", lambda path: []) + + scheduler = sch.Scheduler(executor=lambda actions: None, tick_seconds=0.05) + job = scheduler.add_job("s.json", interval_seconds=0.1) + job.next_run_ts = 0.0 # force it due + + scheduler._tick_once() # must not raise despite start_run exploding + + +def test_tick_survives_snapshot_error(monkeypatch): + """An AutoControlScreenException from capture_error_snapshot (in _fire's + finally) must not escape the tick.""" + class OkHistory: + def start_run(self, *args, **kwargs): + return 1 + + def finish_run(self, *args, **kwargs): + return True + + monkeypatch.setattr(sch, "default_history_store", OkHistory()) + + def boom_snapshot(run_id): + raise AutoControlScreenException("no display") + + monkeypatch.setattr(sch, "capture_error_snapshot", boom_snapshot) + monkeypatch.setattr(sch, "read_action_json", lambda path: []) + + def failing_exec(actions): + raise AutoControlActionException("bad action") + + scheduler = sch.Scheduler(executor=failing_exec, tick_seconds=0.05) + job = scheduler.add_job("s.json", interval_seconds=0.1) + job.next_run_ts = 0.0 + + scheduler._tick_once() # must not raise + + +def test_scheduler_thread_stays_alive_on_db_error(monkeypatch): + class BoomHistory: + def start_run(self, *args, **kwargs): + raise sqlite3.OperationalError("locked") + + def finish_run(self, *args, **kwargs): + return True + + monkeypatch.setattr(sch, "default_history_store", BoomHistory()) + monkeypatch.setattr(sch, "capture_error_snapshot", lambda run_id: None) + monkeypatch.setattr(sch, "read_action_json", lambda path: []) + + scheduler = sch.Scheduler(executor=lambda actions: None, tick_seconds=0.05) + scheduler.add_job("s.json", interval_seconds=0.05) + scheduler.start() + time.sleep(0.3) + try: + assert scheduler._thread.is_alive() + finally: + scheduler.stop() + + +# --- finding 5: ACME renewal ---------------------------------------------- + +def test_raising_on_failure_hook_does_not_escape_tick(tmp_path): + cert = tmp_path / "missing.pem" # missing -> renewal_due True + + def bad_renew(): + raise RuntimeError("certbot failed") + + def bad_hook(_error): + raise ValueError("alerting is down") + + scheduler = renewal.RenewalScheduler( + cert, bad_renew, on_failure=bad_hook, check_interval_s=999) + + assert scheduler.tick() is True # hook raises but must not propagate + + +def test_renewal_loop_survives_raising_tick(monkeypatch, tmp_path): + cert = tmp_path / "missing.pem" + scheduler = renewal.RenewalScheduler( + cert, lambda: None, check_interval_s=0.05) + + def boom_tick(): + raise RuntimeError("kaboom") + + monkeypatch.setattr(scheduler, "tick", boom_tick) + scheduler.start() + time.sleep(0.2) + try: + assert scheduler.is_running # thread survived the raising tick + finally: + scheduler.stop() + + +# --- finding 6: screen observer ------------------------------------------- + +def test_image_predicate_absent_does_not_kill_poll_once(monkeypatch): + def raise_not_found(*_args, **_kwargs): + raise ImageNotFoundException("absent") + + monkeypatch.setattr(image_mod, "locate_image_center", raise_not_found) + observer = ScreenObserver() + observer.add("img", image_predicate("x.png"), lambda ev, val: None) + + assert observer.poll_once() == [] # contained, no event, no raise + + +def test_handler_raising_framework_exc_is_contained(): + observer = ScreenObserver() + + def handler(_event, _value): + raise AutoControlKeyboardException("bad key") + + observer.add("appear", lambda: True, handler) + events = observer.poll_once() # handler raises but is contained + assert len(events) == 1 # transition still recorded + + +# --- finding 7: popup watchdog -------------------------------------------- + +def test_action_raising_keyboard_exc_does_not_kill_check(): + watchdog = PopupWatchdog() + + def action(): + raise AutoControlCantFindKeyException("no such key") + + watchdog.add_rule( + WatchdogRule(name="r", matcher=lambda: True, action=action)) + + assert watchdog.check_once() == 0 # rule error contained, no raise diff --git a/test/unit_test/headless/test_r3_net_chatops.py b/test/unit_test/headless/test_r3_net_chatops.py new file mode 100644 index 00000000..40c90089 --- /dev/null +++ b/test/unit_test/headless/test_r3_net_chatops.py @@ -0,0 +1,102 @@ +"""Round-3 net audit: chat-ops must reply on failure, never kill the poller. + +An in-flight run's ``None`` duration made ``/status`` raise TypeError; the +router did not catch framework/DB errors; and a transient reply failure could +re-execute an already-run command because ``last_seen_ts`` was only committed +after the whole batch. All three would silently stop ``run_forever``. +""" +import sqlite3 + +import pytest + +from je_auto_control.utils.chatops import handlers +from je_auto_control.utils.chatops.router import CommandResult, CommandRouter +from je_auto_control.utils.chatops.slack_bot import SlackBot, SlackError +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, +) +from je_auto_control.utils.run_history.history_store import RunRecord + + +# --- finding 10a: None duration in /status -------------------------------- + +def test_cmd_status_handles_in_flight_none_duration(monkeypatch): + row = RunRecord( + id=1, source_type="scheduler", source_id="j1", script_path="s.json", + started_at=100.0, finished_at=None, status="running", error_text=None) + + class FakeStore: + def list_runs(self, limit=5): + return [row] + + monkeypatch.setattr( + "je_auto_control.utils.run_history.history_store." + "default_history_store", FakeStore()) + + result = handlers.cmd_status([], {}) # must not raise TypeError + assert "in progress" in result.text + + +# --- finding 10b: router contains framework / DB errors ------------------- + +def test_router_returns_reply_on_framework_exc(): + router = CommandRouter() + + def boom(_argv, _ctx): + raise AutoControlActionException("script blew up") + + router.register("run", boom) + result = router.dispatch("/run x") + + assert result is not None + assert result.succeeded is False + assert "run failed" in result.text + + +def test_router_returns_reply_on_db_error(): + router = CommandRouter() + + def boom(_argv, _ctx): + raise sqlite3.OperationalError("database is locked") + + router.register("status", boom) + result = router.dispatch("/status") + + assert result is not None + assert result.succeeded is False + + +# --- finding 10c: slack_bot commits last_seen_ts per message -------------- + +def test_last_seen_ts_committed_before_reply(monkeypatch): + """A reply-post failure must not cause the command to run twice.""" + executed: list = [] + router = CommandRouter() + + def do_run(_argv, _ctx): + executed.append(1) + return CommandResult(text="ran") + + router.register("run", do_run) + bot = SlackBot(token="xoxb-test", channel_id="C1", router=router) + + # Slack returns newest-first; only the older message ("1") is reached + # before the reply fails. + monkeypatch.setattr(bot, "_fetch_messages", lambda: [ + {"ts": "2", "text": "/run a", "user": "U1"}, + {"ts": "1", "text": "/run b", "user": "U1"}, + ]) + monkeypatch.setattr(bot, "_is_self", lambda msg: False) + + def failing_post(_text, *, thread_ts=""): + raise SlackError("reply failed") + + monkeypatch.setattr(bot, "post_message", failing_post) + + with pytest.raises(SlackError): + bot.poll_once() + + # The command ran once and its ts was committed *before* the reply, so the + # next poll (Slack's `oldest` is exclusive) will not re-run it. + assert executed == [1] + assert bot.last_seen_ts == "1" diff --git a/test/unit_test/headless/test_r3_net_socket_rest.py b/test/unit_test/headless/test_r3_net_socket_rest.py new file mode 100644 index 00000000..d9ecea92 --- /dev/null +++ b/test/unit_test/headless/test_r3_net_socket_rest.py @@ -0,0 +1,147 @@ +"""Round-3 net audit: socket + REST request handlers must answer, not drop. + +The TCP command server truncated any command that spanned more than one TCP +read and killed its handler thread (leaving the client with a bare EOF) on a +framework error. The REST dispatcher dropped the connection on a sqlite3.Error +from a DB-backed handler. Both must instead reassemble the request and always +send a well-formed response. +""" +import sqlite3 + +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, AutoControlScreenException, +) +from je_auto_control.utils.socket_server import ( + auto_control_socket_server as ss, +) +from je_auto_control.utils.rest_api import rest_server as rs + + +class _FakeRequest: + """Minimal socket stand-in: canned recv chunks + captured sendall bytes.""" + + def __init__(self, chunks) -> None: + self._chunks = list(chunks) + self.sent: list = [] + + def recv(self, _bufsize) -> bytes: + if self._chunks: + return self._chunks.pop(0) + return b"" # EOF + + def sendall(self, data) -> None: + self.sent.append(data) + + +# --- finding 8: socket server --------------------------------------------- + +def test_read_command_reassembles_large_message(): + """A >8 KiB command spread across TCP reads must not be truncated.""" + payload = "x" * 20000 + chunks = [payload[i:i + 8192].encode("utf-8") + for i in range(0, len(payload), 8192)] + chunks.append(b"\n") # terminator arrives in a later read + request = _FakeRequest(chunks) + + result = ss._read_command(request) + + assert result == payload # full 20 KiB, not the first 8192 bytes + + +def test_handle_sends_error_and_terminator_on_framework_exc(monkeypatch): + """An unknown-command framework error must reach the client, not kill the + handler thread with no reply.""" + def boom(_payload): + raise AutoControlActionException("unknown command") + + monkeypatch.setattr(ss, "execute_action", boom) + + handler = ss.TCPServerHandler.__new__(ss.TCPServerHandler) + request = _FakeRequest([b'{"AC_bogus": {}}\n']) + handler.request = request + handler.server = object() + + handler.handle() # must not raise + + joined = b"".join(request.sent) + assert b"Return_Data_Over_JE" in joined # terminator always sent + assert b"unknown command" in joined # the error text reached the client + + +# --- finding 9: REST dispatcher ------------------------------------------- + +class _Gate: + def check(self, **_kwargs) -> str: + return "ok" + + +class _Metrics: + def __init__(self) -> None: + self.requests: list = [] + + def record_request(self, method, path, status) -> None: + self.requests.append((method, path, status)) + + def record_failed_auth(self) -> None: + pass + + +class _Audit: + def __init__(self) -> None: + self.logs: list = [] + + def log(self, *args, **kwargs) -> None: + self.logs.append((args, kwargs)) + + +def _make_rest_handler(): + handler = rs._RestRequestHandler.__new__(rs._RestRequestHandler) + + class _Server: + pass + + server = _Server() + server.auth_gate = _Gate() + server.metrics = _Metrics() + server.audit_log = _Audit() + handler.server = server + handler.client_address = ("127.0.0.1", 5555) + handler.headers = {} + handler.path = "/history" + + sent: dict = {} + + def fake_send_json(payload, status=200, default=None): + sent["payload"] = payload + sent["status"] = status + + handler._send_json = fake_send_json + return handler, server, sent + + +def test_dispatch_contains_framework_family(): + """The reparent makes AutoControlException contain the whole family.""" + handler, server, sent = _make_rest_handler() + + def boom(_ctx): + raise AutoControlScreenException("no display") + + handler._dispatch("GET", {"/history": boom}, body=None) + + assert sent["status"] == 500 + assert sent["payload"] == {"error": "handler crashed"} + assert any(status == 500 for _, _, status in server.metrics.requests) + assert server.audit_log.logs # failure was audited + + +def test_dispatch_contains_sqlite_error(): + """A DB error from a handler must return 500, not drop the connection.""" + handler, server, sent = _make_rest_handler() + + def boom(_ctx): + raise sqlite3.OperationalError("database is locked") + + handler._dispatch("GET", {"/history": boom}, body=None) + + assert sent["status"] == 500 + assert any(status == 500 for _, _, status in server.metrics.requests) diff --git a/test/unit_test/headless/test_r3_net_triggers.py b/test/unit_test/headless/test_r3_net_triggers.py new file mode 100644 index 00000000..221e0058 --- /dev/null +++ b/test/unit_test/headless/test_r3_net_triggers.py @@ -0,0 +1,159 @@ +"""Round-3 net audit: trigger poll loops must contain framework exceptions. + +Every framework error now subclasses ``AutoControlException``. These triggers +reach out to the screen / a script file — all of which raise members of that +family in the *normal* course of polling (image not on screen yet, script +renamed). Before the fix their catch tuples missed the base, so the offender +was permanently disabled (image/pixel/window triggers), a bad email re-fired +forever, or an HTTP webhook client got a dropped connection. +""" +import time + +import pytest + +import je_auto_control.wrapper.auto_control_image as image_mod +import je_auto_control.wrapper.auto_control_screen as screen_mod +import je_auto_control.wrapper.auto_control_window as window_mod +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, AutoControlJsonActionException, + AutoControlScreenException, ImageNotFoundException, +) +from je_auto_control.utils.triggers import email_trigger as et +from je_auto_control.utils.triggers import webhook_server as ws +from je_auto_control.utils.triggers.trigger_engine import ( + ImageAppearsTrigger, PixelColorTrigger, TriggerEngine, WindowAppearsTrigger, +) + + +class _FakeHistory: + """Records finish_run status so a test can assert OK vs ERROR.""" + + def __init__(self) -> None: + self.finished: list = [] + + def start_run(self, *args, **kwargs) -> int: + return 1 + + def finish_run(self, run_id, status, error_text, artifact_path=None) -> bool: + self.finished.append((run_id, status, error_text)) + return True + + +# --- finding 1: image / pixel / window triggers ---------------------------- + +def test_image_trigger_returns_false_when_image_absent(monkeypatch): + """ImageNotFoundException on an absent template is the normal poll case.""" + def raise_not_found(*_args, **_kwargs): + raise ImageNotFoundException("absent") + + monkeypatch.setattr(image_mod, "locate_image_center", raise_not_found) + trigger = ImageAppearsTrigger( + trigger_id="img", script_path="s.json", image_path="x.png") + assert trigger.is_fired() is False # must not raise + + +def test_pixel_trigger_returns_false_on_screen_exception(monkeypatch): + def raise_screen(*_args, **_kwargs): + raise AutoControlScreenException("no display") + + monkeypatch.setattr(screen_mod, "get_pixel", raise_screen) + trigger = PixelColorTrigger( + trigger_id="px", script_path="s.json", x=1, y=1) + assert trigger.is_fired() is False + + +def test_window_trigger_returns_false_on_framework_exception(monkeypatch): + def raise_action(*_args, **_kwargs): + raise AutoControlActionException("backend blew up") + + monkeypatch.setattr(window_mod, "find_window", raise_action) + trigger = WindowAppearsTrigger( + trigger_id="win", script_path="s.json", title_substring="Save") + assert trigger.is_fired() is False + + +def test_engine_keeps_image_trigger_enabled_when_image_absent(monkeypatch): + """End-to-end: a normally-absent image must not disable the trigger.""" + def raise_not_found(*_args, **_kwargs): + raise ImageNotFoundException("absent") + + monkeypatch.setattr(image_mod, "locate_image_center", raise_not_found) + engine = TriggerEngine(executor=lambda actions: None, tick_seconds=0.05) + engine.add(ImageAppearsTrigger( + trigger_id="img", script_path="s.json", image_path="x.png", + repeat=True)) + engine.start() + time.sleep(0.3) + try: + assert engine._thread.is_alive() + assert engine._triggers["img"].enabled is True + finally: + engine.stop() + + +# --- finding 2: email trigger --------------------------------------------- + +def test_decode_header_unknown_charset_does_not_raise(): + """An unknown charset raises LookupError from the codec lookup.""" + from email.header import decode_header, make_header + poisoned = "=?x-unknown-charset?B?SGVsbG8=?=" + # Prove the input genuinely triggers the LookupError path the old + # `except ValueError` could not catch. + with pytest.raises(LookupError): + str(make_header(decode_header(poisoned))) # NOSONAR python:S2201 # reason: called for its raising side effect (proves the LookupError path) + # The helper must swallow it and fall back to the raw value. + assert et._decode_header_value(poisoned) == poisoned + + +def test_email_fire_marks_uid_seen_and_records_error_on_missing_script( + monkeypatch): + """A missing script must record STATUS_ERROR and stop the uid re-firing.""" + fake_hist = _FakeHistory() + monkeypatch.setattr(et, "default_history_store", fake_hist) + monkeypatch.setattr(et, "capture_error_snapshot", lambda run_id: None) + + def missing_script(_path): + raise AutoControlJsonActionException("script gone") + + monkeypatch.setattr(et, "read_action_json", missing_script) + monkeypatch.setattr(et, "_fetch_message", lambda client, uid: object()) + monkeypatch.setattr(et, "_build_payload", + lambda uid, msg: {"email.uid": uid}) + monkeypatch.setattr(et, "_mark_seen", lambda client, uid: None) + + watcher = et.EmailTriggerWatcher( + executor=lambda actions, variables: None) + trigger = et.EmailTrigger( + trigger_id="t1", host="h", username="u", password="p", + script_path="missing.json") + + fired = watcher._fire_for_uid(client=object(), trigger=trigger, uid=b"42") + + assert fired == 1 + assert "42" in trigger._seen_uids # processed -> no infinite re-fire + assert fake_hist.finished, "a run must have been finished" + assert fake_hist.finished[-1][1] == "error" # not a bogus STATUS_OK + assert trigger.last_error is not None + + +# --- finding 3: webhook server -------------------------------------------- + +def test_webhook_fire_records_failure_and_returns_when_script_raises( + monkeypatch): + """A framework error must set last_status=500 and let fire() return.""" + fake_hist = _FakeHistory() + monkeypatch.setattr(ws, "default_history_store", fake_hist) + monkeypatch.setattr(ws, "capture_error_snapshot", lambda run_id: None) + monkeypatch.setattr(ws, "read_action_json", lambda path: [{"AC_x": {}}]) + + def boom(actions, variables): + raise AutoControlActionException("unknown command") + + server = ws.WebhookTriggerServer(executor=boom) + trigger = server.add("/hook", "script.json") + + run_id = server.fire(trigger, {"webhook.method": "POST"}) # must not raise + + assert run_id is not None + assert trigger.last_status == 500 + assert fake_hist.finished[-1][1] == "error" diff --git a/test/unit_test/headless/test_r3_platform_linux.py b/test/unit_test/headless/test_r3_platform_linux.py new file mode 100644 index 00000000..34577638 --- /dev/null +++ b/test/unit_test/headless/test_r3_platform_linux.py @@ -0,0 +1,106 @@ +"""Round-3 platform regression tests: Linux (Wayland + X11) backends. + +Headless — no real input is dispatched and no X server is contacted. The +Wayland helpers are pure bit math; the X11 ``get_pixel`` decode is driven +with a synthetic pixel buffer via ``sys.modules`` stubs so the Linux-only +module imports on any host. + +Covers audit findings: +* #3 Wayland ``press_mouse`` must emit a button-down edge only (hold), + ``release_mouse`` an up edge only. +* #4 X11 ``get_pixel`` must return true ``(R, G, B)``, not raw BGR bytes. +* #6 Wayland listener must expose ``check_key_is_press`` so the + critical-exit watcher does not ``AttributeError``. +""" +import importlib +import sys +import types + +import je_auto_control # noqa: F401 # load the facade under the real platform first + +_DOWN_BIT = 0x40 +_UP_BIT = 0x80 + + +def test_finding3_wayland_press_is_down_edge_only(): + """press -> down-bit set / up-bit clear; release -> up-bit set / down clear.""" + from je_auto_control.linux_wayland import mouse as wayland_mouse + + buttons = ( + wayland_mouse.wayland_mouse_left, + wayland_mouse.wayland_mouse_middle, + wayland_mouse.wayland_mouse_right, + ) + for button in buttons: + press = wayland_mouse._press_code(button) + release = wayland_mouse._release_code(button) + # press holds the button: down edge only. + assert press & _DOWN_BIT + assert not press & _UP_BIT + # release lifts the button: up edge only. + assert release & _UP_BIT + assert not release & _DOWN_BIT + # the button selector (low nibble) is preserved in both. + assert press & 0x0F == button & 0x0F + assert release & 0x0F == button & 0x0F + + # Concretely for left (0xC0 = down|up of button 0). + assert wayland_mouse._press_code(0xC0) == 0x40 + assert wayland_mouse._release_code(0xC0) == 0x80 + + +def test_finding6_wayland_listener_has_check_key_is_press(): + """The name must exist (best-effort False) so callers degrade, not crash.""" + from je_auto_control.linux_wayland import listener + + assert hasattr(listener, "check_key_is_press") + assert "check_key_is_press" in listener.__all__ + # positional (critical-exit) and keyword (wrapper) call styles both work. + assert listener.check_key_is_press(65) is False + assert listener.check_key_is_press(keycode=65) is False + + +def test_finding4_x11_get_pixel_returns_rgb(monkeypatch): + """A little-endian BGRX buffer must decode to (R, G, B).""" + monkeypatch.setattr(sys, "platform", "linux") + + fake_xlib = types.ModuleType("Xlib") + fake_xlib.X = types.SimpleNamespace(ZPixmap=2) + monkeypatch.setitem(sys.modules, "Xlib", fake_xlib) + + class _FakeImage: + # bytes on the wire: Blue, Green, Red, unused. + data = bytes([0x10, 0x20, 0x30, 0xFF]) + + class _FakeRoot: + def get_image(self, *_args, **_kwargs): + return _FakeImage() + + class _FakeScreen: + root = _FakeRoot() + width_in_pixels = 100 + height_in_pixels = 100 + + class _FakeDisplay: + def screen(self): + return _FakeScreen() + + display_mod = types.ModuleType("x11_linux_display") + display_mod.display = _FakeDisplay() + monkeypatch.setitem( + sys.modules, + "je_auto_control.linux_with_x11.core.utils.x11_linux_display", + display_mod, + ) + monkeypatch.delitem( + sys.modules, + "je_auto_control.linux_with_x11.screen.x11_linux_screen", + raising=False, + ) + + screen_mod = importlib.import_module( + "je_auto_control.linux_with_x11.screen.x11_linux_screen" + ) + # Blue=0x10, Green=0x20, Red=0x30 -> (R, G, B) = (0x30, 0x20, 0x10). + assert screen_mod.get_pixel(0, 0) == (0x30, 0x20, 0x10) + assert screen_mod._decode_pixel(bytes([1, 2, 3, 4])) == (3, 2, 1) diff --git a/test/unit_test/headless/test_r3_platform_scroll_guard.py b/test/unit_test/headless/test_r3_platform_scroll_guard.py new file mode 100644 index 00000000..4b1f85c5 --- /dev/null +++ b/test/unit_test/headless/test_r3_platform_scroll_guard.py @@ -0,0 +1,75 @@ +"""Round-3 platform regression test: scroll cursor-query guard. + +Headless — the backend ``mouse`` object and the cursor helpers are all +monkeypatched, so nothing touches real input or the screen. + +Covers audit finding #5: ``mouse_scroll(value, x, y)`` with BOTH +coordinates supplied must NOT query the cursor position (which raises +``NotImplementedError`` on Wayland); it should only be queried to fill in +a coordinate that was actually omitted. +""" +import je_auto_control # noqa: F401 # load the facade under the real platform first +from je_auto_control.wrapper import auto_control_mouse as acm + + +class _FakeMouse: + """Records scroll calls; never touches real input.""" + + def __init__(self): + self.scroll_calls = 0 + + def scroll(self, *_args, **_kwargs): + self.scroll_calls += 1 + + +def _install_common(monkeypatch, get_position): + """Wire the module globals to fakes and return a call counter dict.""" + calls = {"set_pos": 0} + fake_mouse = _FakeMouse() + monkeypatch.setattr(acm, "get_mouse_position", get_position) + monkeypatch.setattr(acm, "screen_size", lambda: (1920, 1080)) + monkeypatch.setattr(acm, "mouse", fake_mouse) + monkeypatch.setattr(acm, "special_mouse_keys_table", {}, raising=False) + + def _set_pos(x, y): + calls["set_pos"] += 1 + return x, y + + monkeypatch.setattr(acm, "set_mouse_position", _set_pos) + calls["mouse"] = fake_mouse + return calls + + +def test_finding5_both_coords_do_not_query_cursor(monkeypatch): + """With x and y both given, the cursor must never be queried.""" + queried = {"count": 0} + + def _must_not_query(): + queried["count"] += 1 + raise RuntimeError("cursor query must not happen when both coords given") + + calls = _install_common(monkeypatch, _must_not_query) + + # Before the fix this raised RuntimeError (cursor queried unconditionally). + acm.mouse_scroll(5, x=100, y=200) + + assert queried["count"] == 0 + assert calls["set_pos"] == 1 + assert calls["mouse"].scroll_calls == 1 + + +def test_finding5_missing_coord_still_queries_cursor(monkeypatch): + """When a coordinate is omitted the cursor IS queried to fill it in.""" + queried = {"count": 0} + + def _query(): + queried["count"] += 1 + return (7, 8) + + calls = _install_common(monkeypatch, _query) + + acm.mouse_scroll(5, x=None, y=200) + + assert queried["count"] == 1 + assert calls["set_pos"] == 1 + assert calls["mouse"].scroll_calls == 1 diff --git a/test/unit_test/headless/test_r3_platform_windows_mouse.py b/test/unit_test/headless/test_r3_platform_windows_mouse.py new file mode 100644 index 00000000..4e38ffe3 --- /dev/null +++ b/test/unit_test/headless/test_r3_platform_windows_mouse.py @@ -0,0 +1,84 @@ +"""Round-3 platform regression tests: Windows mouse backend. + +Headless — no real mouse/keyboard input is dispatched. The backend layer +(``_send_stroke`` / ``PostMessageW``) is monkeypatched or exercised only +through pure helpers, so nothing touches the OS input APIs. + +Covers audit findings: +* #1 ``mouse_keys_table`` must be built from the *selected* backend. +* #2 Interception ``scroll`` must scale a notch count by ``WHEEL_DELTA``. +* #7 ``send_mouse_event_to_window`` must post WM_* messages, not the + SendInput button tuple. +* #8 ``SendInput.argtypes`` typo (``arg_types``) must be corrected. +""" +import ctypes +import sys +from ctypes import wintypes + +import pytest + +pytestmark = pytest.mark.skipif( + sys.platform not in ("win32", "cygwin", "msys"), + reason="Windows input backend modules only import on Windows.", +) + + +def test_finding1_mouse_keys_table_built_from_selected_backend(): + """The table reflects the selected backend's flag tuples, not the + import-time SendInput globals.""" + from je_auto_control.wrapper import _platform_windows as pw + from je_auto_control.windows.interception import mouse as interception_mouse + from je_auto_control.windows.mouse import win32_ctype_mouse_control as sendinput_mouse + + table = pw._build_mouse_keys_table(interception_mouse) + # Built from the module handed in — Interception constants, not SendInput. + assert table["mouse_left"] == interception_mouse.win32_mouse_left + assert table["mouse_right"] == interception_mouse.win32_mouse_right + # Interception bitmasks differ from SendInput dwFlags: proves the + # distinction the bug erased. + assert interception_mouse.win32_mouse_left != sendinput_mouse.win32_mouse_left + assert table["mouse_left"] != sendinput_mouse.win32_mouse_left + # The live module table matches whatever backend it actually selected. + assert pw.mouse_keys_table["mouse_left"] == pw.mouse.win32_mouse_left + + +def test_finding2_interception_scroll_scales_by_wheel_delta(monkeypatch): + """One notch must move one notch: rolling == value * 120.""" + from je_auto_control.windows.interception import mouse as interception_mouse + + captured = {} + + def _fake_send(state, *, flags=0, x=0, y=0, rolling=0): + captured["state"] = state + captured["rolling"] = rolling + + monkeypatch.setattr(interception_mouse, "_send_stroke", _fake_send) + interception_mouse.scroll(3) + assert captured["state"] == interception_mouse.MOUSE_WHEEL + assert captured["rolling"] == 3 * 120 + + +def test_finding7_window_message_translation_not_raw_tuple(): + """The window path resolves a button tuple into integer WM_* messages.""" + from je_auto_control.utils.exception.exceptions import AutoControlException + from je_auto_control.windows.mouse import win32_ctype_mouse_control as sendinput_mouse + + down, up = sendinput_mouse._resolve_window_messages(sendinput_mouse.win32_mouse_left) + assert down == (sendinput_mouse.WM_LBUTTONDOWN, sendinput_mouse.MK_LBUTTON) + assert up == (sendinput_mouse.WM_LBUTTONUP, 0) + # A window message is an int — never the SendInput dwFlags tuple. + assert isinstance(down[0], int) + assert down[0] != sendinput_mouse.win32_mouse_left + + right_down, _ = sendinput_mouse._resolve_window_messages(sendinput_mouse.win32_mouse_right) + assert right_down[0] == sendinput_mouse.WM_RBUTTONDOWN + + with pytest.raises(AutoControlException): + sendinput_mouse._resolve_window_messages((9, 9, 9)) + + +def test_finding8_sendinput_argtypes_applied(): + """The ``argtypes`` (not ``arg_types``) attribute must be set.""" + from je_auto_control.windows.core.utils import win32_ctype_input as mod + + assert mod.user32.SendInput.argtypes == (wintypes.UINT, ctypes.c_void_p, ctypes.c_int) diff --git a/test/unit_test/headless/test_r3_rdusb_acl.py b/test/unit_test/headless/test_r3_rdusb_acl.py new file mode 100644 index 00000000..a3da0260 --- /dev/null +++ b/test/unit_test/headless/test_r3_rdusb_acl.py @@ -0,0 +1,45 @@ +"""Audit round 3 regression for the USB passthrough ACL (finding 11). + +vid/pid are hex strings and must compare case-insensitively; otherwise a rule +written in one case silently fails to match a device reporting the other case, +which bypasses a DENY rule when the default policy is "allow". +""" +from je_auto_control.utils.usb.passthrough.acl import AclRule, UsbAcl + + +def test_uppercase_deny_rule_matches_lowercase_device(tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json", default_policy="allow") + acl.add_rule( + AclRule(vendor_id="1D6B", product_id="0002", allow=False), + persist=False, + ) + # Device reports lowercase hex; the uppercase DENY rule must still apply. + assert acl.decide(vendor_id="1d6b", product_id="0002", + serial=None) == "deny" + + +def test_lowercase_deny_rule_matches_uppercase_device(tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json", default_policy="allow") + acl.add_rule( + AclRule(vendor_id="1d6b", product_id="0002", allow=False), + persist=False, + ) + assert acl.decide(vendor_id="1D6B", product_id="0002", + serial=None) == "deny" + + +def test_non_matching_vid_still_falls_through_to_default(tmp_path): + acl = UsbAcl(path=tmp_path / "acl.json", default_policy="allow") + acl.add_rule( + AclRule(vendor_id="1D6B", product_id="0002", allow=False), + persist=False, + ) + # A genuinely different device is unaffected by the rule. + assert acl.decide(vendor_id="dead", product_id="beef", + serial=None) == "allow" + + +def test_rule_matches_predicate_is_case_insensitive(): + rule = AclRule(vendor_id="ABCD", product_id="00FF") + assert rule.matches(vendor_id="abcd", product_id="00ff", serial=None) + assert rule.matches(vendor_id="ABCD", product_id="00FF", serial=None) diff --git a/test/unit_test/headless/test_r3_rdusb_host.py b/test/unit_test/headless/test_r3_rdusb_host.py new file mode 100644 index 00000000..8dace307 --- /dev/null +++ b/test/unit_test/headless/test_r3_rdusb_host.py @@ -0,0 +1,102 @@ +"""Audit round 3 regressions for RemoteDesktopHost (loopback sockets only). + +Covers: +* Finding 1 — dead handlers must be reaped *before* the max_clients check, + otherwise a full table of disconnected handlers rejects every new viewer + forever. +* Finding 3 — the initial cursor + frame are sent from the per-client sender + thread, not the shared accept thread, so a slow-reading viewer cannot block + new connections. +""" +import threading +import time + +from je_auto_control.utils.remote_desktop import ( + RemoteDesktopHost, RemoteDesktopViewer, +) +from je_auto_control.utils.remote_desktop.host import _ClientHandler + + +def _wait_until(predicate, timeout: float = 5.0, + interval: float = 0.02) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(interval) + return predicate() + + +class _DeadHandler: + """Stand-in for a client whose viewer already disconnected.""" + + def __init__(self) -> None: + self._shutdown = threading.Event() + self._shutdown.set() + self.authenticated = False + + def stop(self) -> None: + pass + + def _close(self) -> None: + pass + + +def _make_host(**kwargs) -> RemoteDesktopHost: + params = dict( + token="tok", bind="127.0.0.1", port=0, + frame_provider=lambda: b"x", + input_dispatcher=lambda message: None, + enable_cursor_broadcast=False, + ) + params.update(kwargs) + return RemoteDesktopHost(**params) + + +def test_reap_runs_before_capacity_check_admits_new_viewer(): + """A table full of dead handlers must not permanently reject new viewers.""" + host = _make_host(max_clients=1) + host.start() + try: + # Seed a dead handler so the (max_clients==1) table looks "full". + with host._clients_lock: + host._clients.append(_DeadHandler()) + viewer = RemoteDesktopViewer("127.0.0.1", host.port, "tok") + viewer.connect(timeout=5.0) + try: + assert _wait_until(lambda: host.connected_clients == 1) + finally: + viewer.disconnect(timeout=2.0) + finally: + host.stop(timeout=2.0) + + +def test_slow_viewer_initial_frame_does_not_block_accept(monkeypatch): + """Blocking the initial frame send must stall only that viewer's thread.""" + block = threading.Event() + + def _blocking_initial_frame(self) -> None: + # Emulate a viewer that authenticated then stopped reading: the + # initial full-screen frame send parks here until released. + block.wait(timeout=5.0) + + monkeypatch.setattr( + _ClientHandler, "_send_initial_frame", _blocking_initial_frame, + ) + host = _make_host(max_clients=4) + host.start() + viewers = [] + try: + for _ in range(2): + viewer = RemoteDesktopViewer("127.0.0.1", host.port, "tok") + # Pre-fix the accept thread blocks inside viewer #1's initial + # frame send, so viewer #2 never receives AUTH_CHALLENGE and this + # connect() raises on timeout. + viewer.connect(timeout=3.0) + viewers.append(viewer) + assert _wait_until(lambda: host.connected_clients == 2) + finally: + block.set() + for viewer in viewers: + viewer.disconnect(timeout=2.0) + host.stop(timeout=2.0) diff --git a/test/unit_test/headless/test_r3_rdusb_hotkey.py b/test/unit_test/headless/test_r3_rdusb_hotkey.py new file mode 100644 index 00000000..da09f163 --- /dev/null +++ b/test/unit_test/headless/test_r3_rdusb_hotkey.py @@ -0,0 +1,68 @@ +"""Audit round 3 regression for the Linux X11 hotkey grab (finding 12). + +_grab_masked registers a hotkey under several lock-mask variants. If a later +variant fails it must roll back the ones that already succeeded, otherwise the +grab leaks (and _registered is never updated, so every poll re-grabs and spams +BadAccess). + +Xlib is not installed on the test host, so a minimal fake module is injected. +""" +import sys +import types + +from je_auto_control.utils.hotkey.backends.linux_backend import ( + LinuxHotkeyBackend, +) + + +def _install_fake_xlib(monkeypatch) -> None: + fake_x = types.SimpleNamespace( + GrabModeAsync=1, Mod2Mask=0x10, LockMask=0x02, + ) + fake_xlib = types.ModuleType("Xlib") + fake_xlib.X = fake_x + monkeypatch.setitem(sys.modules, "Xlib", fake_xlib) + + +def test_grab_masked_rolls_back_on_partial_failure(monkeypatch): + _install_fake_xlib(monkeypatch) + binding = types.SimpleNamespace(combo="ctrl+a", binding_id="b1") + grab_masks = [] + ungrab_masks = [] + + class _Root: + def grab_key(self, keycode, mask, *_args): + grab_masks.append(mask) + if len(grab_masks) == 3: # variants: 0, Mod2, Lock, Mod2|Lock + raise RuntimeError("BadAccess") + + def ungrab_key(self, keycode, mask): + ungrab_masks.append(mask) + + ok = LinuxHotkeyBackend._grab_masked( + _Root(), binding, mask=0x04, keycode=38, + ) + + assert ok is False + assert grab_masks == [0x04, 0x14, 0x06] # base | {0, Mod2, Lock} + # The two grabs that succeeded (extra masks 0 and Mod2) are rolled back. + assert ungrab_masks == [0x04, 0x14] + + +def test_grab_masked_returns_true_when_all_variants_succeed(monkeypatch): + _install_fake_xlib(monkeypatch) + binding = types.SimpleNamespace(combo="ctrl+a", binding_id="b1") + ungrab_masks = [] + + class _Root: + def grab_key(self, keycode, mask, *_args): + pass + + def ungrab_key(self, keycode, mask): + ungrab_masks.append(mask) + + ok = LinuxHotkeyBackend._grab_masked( + _Root(), binding, mask=0x04, keycode=38, + ) + assert ok is True + assert ungrab_masks == [] # nothing rolled back on full success diff --git a/test/unit_test/headless/test_r3_rdusb_input.py b/test/unit_test/headless/test_r3_rdusb_input.py new file mode 100644 index 00000000..3fdd364c --- /dev/null +++ b/test/unit_test/headless/test_r3_rdusb_input.py @@ -0,0 +1,109 @@ +"""Audit round 3 regressions for modifier hold + key hold (findings 9, 10). + +Both primitives must guarantee that keys they press are released even when a +press part-way through fails, a release fails, or the hold is interrupted. +""" +import pytest + +from je_auto_control.utils.key_hold.key_hold import hold_key +from je_auto_control.utils.modifier_state.modifier_state import hold_modifiers + + +# --- Finding 9: hold_modifiers -------------------------------------- + +def test_hold_modifiers_releases_pressed_when_a_later_press_fails(): + events = [] + + def sink(event): + events.append((event["op"], event["key"])) + if event["op"] == "press" and event["key"] == "shift": + raise RuntimeError("second press failed") + + with pytest.raises(RuntimeError): + with hold_modifiers(["ctrl", "shift"], sink=sink): + pass + + # ctrl went down before shift's press failed, so ctrl must be released; + # shift was never pressed, so it must never be released. + assert ("press", "ctrl") in events + assert ("release", "ctrl") in events + assert ("release", "shift") not in events + + +def test_hold_modifiers_release_failure_does_not_skip_others(): + released = [] + + def sink(event): + if event["op"] == "release": + released.append(event["key"]) + if event["key"] == "shift": + raise RuntimeError("release failed") + + # Must not raise: a failing release is logged, not propagated, and the + # remaining modifier is still released. + with hold_modifiers(["ctrl", "shift"], sink=sink): + pass + + assert "shift" in released # reversed order releases shift first + assert "ctrl" in released + + +def test_hold_modifiers_happy_path_release_reversed(): + events = [] + with hold_modifiers(["ctrl", "alt"], + sink=lambda e: events.append((e["op"], e["key"]))): + pass + assert events == [ + ("press", "ctrl"), ("press", "alt"), + ("release", "alt"), ("release", "ctrl"), + ] + + +# --- Finding 10: hold_key ------------------------------------------- + +def test_hold_key_releases_on_interrupt_during_wait(): + dispatched = [] + + def _interrupt(_seconds): + raise KeyboardInterrupt() + + with pytest.raises(KeyboardInterrupt): + hold_key("a", 1.0, + sink=lambda e: dispatched.append((e["op"], e["key"])), + sleep=_interrupt) + + assert ("press", "a") in dispatched + assert ("release", "a") in dispatched + + +def test_hold_key_retries_release_when_first_release_fails(): + calls = [] + + def sink(event): + calls.append((event["op"], event["key"])) + if event["op"] == "release" and calls.count(("release", "a")) == 1: + raise RuntimeError("first release failed") + + with pytest.raises(RuntimeError): + hold_key("a", 0.01, sink=sink, sleep=lambda _s: None) + + # The finally block re-attempts the release the plan step failed to run. + assert calls.count(("release", "a")) == 2 + + +def test_hold_key_happy_path_single_release(): + calls = [] + hold_key("a", 0.01, + sink=lambda e: calls.append((e["op"], e["key"])), + sleep=lambda _s: None) + assert calls == [("press", "a"), ("release", "a")] + + +def test_hold_key_autorepeat_has_no_dangling_release(): + ops = [] + hold_key("a", 1.0, rate_hz=2.0, + sink=lambda e: ops.append(e["op"]), + sleep=lambda _s: None) + assert "press" not in ops + assert "release" not in ops + assert ops.count("key") >= 1 diff --git a/test/unit_test/headless/test_r3_rdusb_relay.py b/test/unit_test/headless/test_r3_rdusb_relay.py new file mode 100644 index 00000000..a1915d51 --- /dev/null +++ b/test/unit_test/headless/test_r3_rdusb_relay.py @@ -0,0 +1,38 @@ +"""Audit round 3 regression for the relay pipe (finding 4). + +When one side of a paired session EOFs, the opposite pipe thread is parked in a +blocking recv(); the ``stop`` flag alone cannot wake it. The fix shuts the +opposite socket down so both threads exit and both sockets are closed instead +of hanging join() forever. +""" +import socket +import threading + +from je_auto_control.utils.remote_desktop.relay import _pair_and_pump + + +def test_pair_and_pump_exits_when_one_side_closes(): + host_a, host_b = socket.socketpair() + viewer_a, viewer_b = socket.socketpair() + done = threading.Event() + + def _run() -> None: + _pair_and_pump(host_b, viewer_b) + done.set() + + threading.Thread(target=_run, daemon=True).start() + + # The host peer disconnects while the viewer side sits idle (parked in + # recv). Pre-fix, the viewer->host pipe thread never wakes and + # _pair_and_pump's join() blocks forever, so done is never set. + host_a.close() + + assert done.wait(timeout=3.0), "_pair_and_pump hung after one side EOF" + assert host_b.fileno() == -1 + assert viewer_b.fileno() == -1 + + for extra in (host_a, viewer_a): + try: + extra.close() + except OSError: + pass diff --git a/test/unit_test/headless/test_r3_rdusb_usbip.py b/test/unit_test/headless/test_r3_rdusb_usbip.py new file mode 100644 index 00000000..11920500 --- /dev/null +++ b/test/unit_test/headless/test_r3_rdusb_usbip.py @@ -0,0 +1,189 @@ +"""Audit round 3 regressions for the USB/IP server + protocol. + +Covers: +* Finding 6 — an OUT CMD_SUBMIT carrying a payload must be decoded in two + phases (peek length, read buffer, decode) so it is forwarded instead of + dropping the connection; oversized transfers are rejected before allocating. +* Finding 7 — RET_SUBMIT status is a signed __s32: a negative errno must encode + without raising struct.error (which used to kill the worker thread). +* Finding 8 — CMD_UNLINK must be answered with RET_UNLINK (0x4), not + RET_SUBMIT (0x3). +""" +import socket +import struct + +import pytest + +from je_auto_control.utils.usbip import ( + FakeUrbBackend, PROTOCOL_VERSION, OP_REQ_IMPORT, USBIP_CMD_SUBMIT, + USBIP_CMD_UNLINK, USBIP_RET_SUBMIT, USBIP_RET_UNLINK, UrbResponse, + UsbIpError, UsbIpServer, encode_ret_submit, +) +from je_auto_control.utils.usbip.protocol import ( + UsbIpDevice, UsbIpInterface, peek_transfer_length, +) +from je_auto_control.utils.usbip.server import _MAX_TRANSFER_BUFFER_BYTES + + +def _device(busid: str = "1-1") -> UsbIpDevice: + return UsbIpDevice( + path=f"/sys/devices/pci0000:00/{busid}", + busid=busid, busnum=1, devnum=2, speed=3, + vendor_id=0x046D, product_id=0xC52B, bcd_device=0x0200, + device_class=0, device_subclass=0, device_protocol=0, + configuration_value=1, num_configurations=1, num_interfaces=1, + interfaces=[UsbIpInterface(3, 0, 0)], + ) + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _recv(sock: socket.socket, n: int) -> bytes: + buf = bytearray() + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + break + buf.extend(chunk) + return bytes(buf) + + +def _import_device(sock: socket.socket, busid: str = "1-1") -> None: + header = struct.pack("!HHI", PROTOCOL_VERSION, OP_REQ_IMPORT, 0) + sock.sendall(header + busid.encode("ascii").ljust(32, b"\x00")) + _recv(sock, 8) # OP_REP_IMPORT header + _recv(sock, 312) # device descriptor body + + +def _cmd_submit(*, seqnum: int, devid: int, direction: int, ep: int, + transfer_length: int, buffer: bytes = b"") -> bytes: + header = struct.pack("!IIIII", USBIP_CMD_SUBMIT, seqnum, devid, + direction, ep) + body = struct.pack("!IIiII8s", 0, transfer_length, 0, 0, 0, b"\x00" * 8) + return header + body + buffer + + +# --- Finding 6 ------------------------------------------------------- + +def test_peek_transfer_length_returns_direction_and_length(): + header = struct.pack("!IIIII", USBIP_CMD_SUBMIT, 1, 2, 0, 3) + body = struct.pack("!IIiII8s", 0, 128, 0, 0, 0, b"\x00" * 8) + direction, tlen = peek_transfer_length(header, body) + assert direction == 0 + assert tlen == 128 + + +def test_out_cmd_submit_with_payload_is_forwarded(): + backend = FakeUrbBackend(devices=[_device("1-1")]) + backend.script_urb(devid=2, direction=0, ep=2, + response=UrbResponse(status=0, actual_length=0)) + server = UsbIpServer(backend, host="127.0.0.1", port=_free_port()) + server.start() + try: + sock = socket.create_connection(("127.0.0.1", server.port), + timeout=5.0) + _import_device(sock) + payload = b"hello-out-transfer" + sock.sendall(_cmd_submit(seqnum=42, devid=2, direction=0, ep=2, + transfer_length=len(payload), + buffer=payload)) + # Pre-fix the first decode raised UsbIpError and the connection was + # dropped before the URB reached the backend. + header = _recv(sock, 20) + assert len(header) == 20 + assert int.from_bytes(header[:4], "big") == USBIP_RET_SUBMIT + _recv(sock, 28) # RET_SUBMIT body + assert len(backend.received) == 1 + assert backend.received[0].transfer_buffer == payload + sock.close() + finally: + server.stop() + + +def test_oversized_transfer_buffer_is_rejected(): + backend = FakeUrbBackend(devices=[_device("1-1")]) + server = UsbIpServer(backend, host="127.0.0.1", port=_free_port()) + server.start() + try: + sock = socket.create_connection(("127.0.0.1", server.port), + timeout=5.0) + _import_device(sock) + huge = _MAX_TRANSFER_BUFFER_BYTES + 1 + # Advertise a huge OUT transfer but send no buffer: the server must + # reject on the length check (dropping the connection) rather than + # try to allocate/read it. + sock.sendall(_cmd_submit(seqnum=7, devid=2, direction=0, ep=1, + transfer_length=huge)) + assert _recv(sock, 1) == b"" # connection closed, no huge alloc + assert backend.received == [] + sock.close() + finally: + server.stop() + + +# --- Finding 7 ------------------------------------------------------- + +def test_ret_submit_encodes_negative_errno_status(): + # Pre-fix this raised struct.error (status packed as unsigned 'I'). + body = encode_ret_submit(seqnum=1, devid=1, direction=1, ep=1, + status=-19, actual_length=0) + status = struct.unpack("!i", body[20:24])[0] + assert status == -19 + + +def test_server_replies_negative_status_without_killing_worker(): + # FakeUrbBackend returns status=-19 (-ENODEV) for any unscripted URB. + backend = FakeUrbBackend(devices=[_device("1-1")]) + server = UsbIpServer(backend, host="127.0.0.1", port=_free_port()) + server.start() + try: + sock = socket.create_connection(("127.0.0.1", server.port), + timeout=5.0) + _import_device(sock) + sock.sendall(_cmd_submit(seqnum=11, devid=2, direction=1, ep=5, + transfer_length=0)) + header = _recv(sock, 20) + body = _recv(sock, 28) + # Pre-fix the worker died on struct.error and these reads got EOF. + assert len(header) == 20 and len(body) == 28 + assert int.from_bytes(header[:4], "big") == USBIP_RET_SUBMIT + assert struct.unpack("!i", body[:4])[0] == -19 + sock.close() + finally: + server.stop() + + +# --- Finding 8 ------------------------------------------------------- + +def test_cmd_unlink_replies_ret_unlink(): + backend = FakeUrbBackend(devices=[_device("1-1")]) + server = UsbIpServer(backend, host="127.0.0.1", port=_free_port()) + server.start() + try: + sock = socket.create_connection(("127.0.0.1", server.port), + timeout=5.0) + _import_device(sock) + unlink = struct.pack("!IIIII", USBIP_CMD_UNLINK, 55, 2, 0, 0) + sock.sendall(unlink + b"\x00" * 28) + header = _recv(sock, 20) + assert len(header) == 20 + command = int.from_bytes(header[:4], "big") + # Pre-fix this was USBIP_RET_SUBMIT (0x3); the client's URB-cancel + # never completed. + assert command == USBIP_RET_UNLINK + assert int.from_bytes(header[4:8], "big") == 55 # echoed seqnum + body = _recv(sock, 28) + assert len(body) == 28 + assert struct.unpack("!i", body[:4])[0] == 0 # status + sock.close() + finally: + server.stop() + + +def test_peek_transfer_length_rejects_short_input(): + with pytest.raises(UsbIpError): + peek_transfer_length(b"\x00" * 4, b"\x00" * 28) diff --git a/test/unit_test/headless/test_r3_rdusb_viewer.py b/test/unit_test/headless/test_r3_rdusb_viewer.py new file mode 100644 index 00000000..b2f7b635 --- /dev/null +++ b/test/unit_test/headless/test_r3_rdusb_viewer.py @@ -0,0 +1,91 @@ +"""Audit round 3 regressions for RemoteDesktopViewer. + +Covers: +* Finding 2 — connect() must close the raw socket when the WebSocket + handshake raises WsProtocolError (a RuntimeError subclass), not leak the fd. +* Finding 5 — disconnect() called from inside a frame/error callback (i.e. on + the receiver thread) must skip the self-join and still finish teardown. +""" +import socket +import threading + +import pytest + +from je_auto_control.utils.remote_desktop import viewer as viewer_module +from je_auto_control.utils.remote_desktop.viewer import RemoteDesktopViewer +from je_auto_control.utils.remote_desktop.ws_protocol import WsProtocolError + + +def test_connect_closes_socket_on_ws_protocol_error(monkeypatch): + """A WsProtocolError during handshake must not leak the raw socket.""" + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + held = [] + + def _accept_once() -> None: + try: + conn, _addr = listener.accept() + held.append(conn) # keep the connection open past handshake fail + except OSError: + pass + + threading.Thread(target=_accept_once, daemon=True).start() + + captured = {} + real_create = socket.create_connection + + def _spy_create(address, timeout=None): + raw = real_create(address, timeout=timeout) + captured["sock"] = raw + return raw + + monkeypatch.setattr(viewer_module.socket, "create_connection", _spy_create) + + class _WsFailViewer(RemoteDesktopViewer): + def _handshake(self, channel): + raise WsProtocolError("bad ws upgrade") + + viewer = _WsFailViewer("127.0.0.1", port, "tok") + try: + with pytest.raises(WsProtocolError): + viewer.connect(timeout=2.0) + # Pre-fix the except tuple omitted WsProtocolError, so the raw socket + # stayed open (fileno() != -1). The fix closes it. + raw = captured["sock"] + assert raw.fileno() == -1 + finally: + listener.close() + for conn in held: + try: + conn.close() + except OSError: + pass + + +def test_disconnect_from_receiver_thread_completes_teardown(): + """disconnect() on the receiver thread must not raise and must tear down.""" + viewer = RemoteDesktopViewer("127.0.0.1", 1, "tok") + viewer._channel = None # skip channel.close() + result = {} + + def _worker() -> None: + viewer._connected = True + try: + viewer.disconnect(timeout=1.0) + result["error"] = None + except RuntimeError as exc: # pre-fix: "cannot join current thread" + result["error"] = exc + + worker = threading.Thread(target=_worker) + # Make the running worker *be* the viewer's receiver thread. + viewer._receiver = worker + worker.start() + worker.join(timeout=3.0) + + assert not worker.is_alive() + assert result.get("error") is None + assert viewer._receiver is None + assert viewer.connected is False diff --git a/test/unit_test/headless/test_r3_util_flow_containment.py b/test/unit_test/headless/test_r3_util_flow_containment.py new file mode 100644 index 00000000..f56e1de8 --- /dev/null +++ b/test/unit_test/headless/test_r3_util_flow_containment.py @@ -0,0 +1,156 @@ +"""Round-3 audit regressions: execution-flow containment fixes. + +Covers findings 1 (DAG runner), 2 (callback executor), 5 (test-suite runner), +and 6 (self-healing replay). Each asserts that a framework exception raised by +an injected callable is *contained* into a result rather than crashing the +surrounding driver. Fully headless — no real input, no Qt. +""" +import types + +import pytest + +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, AutoControlMouseException, +) + + +# --- Finding 1: DAG runner ------------------------------------------------ + +def test_run_dag_contains_framework_exception(): + from je_auto_control.utils.dag.runner import ( + STATUS_FAILED, STATUS_SKIPPED, run_dag, + ) + definition = {"nodes": [ + {"id": "a", "actions": [["AC_noop", {}]]}, + {"id": "b", "actions": [["AC_noop", {}]], "depends_on": ["a"]}, + ]} + + def boom_runner(node, _definition): + raise AutoControlActionException(f"bad command in {node.id}") + + result = run_dag(definition, local_runner=boom_runner) + assert result.succeeded is False + assert result.nodes["a"].status == STATUS_FAILED + assert "AutoControlActionException" in (result.nodes["a"].error or "") + # Downstream node is skipped (cascade), never left "running". + assert result.nodes["b"].status == STATUS_SKIPPED + + +# --- Finding 2: callback executor ---------------------------------------- + +def test_callback_failure_preserves_trigger_result(): + from je_auto_control.utils.callback.callback_function_executor import ( + CallbackFunctionExecutor, + ) + executor = CallbackFunctionExecutor() + executor.event_dict["_r3_trigger_ok"] = lambda **_kw: "TRIGGER_OK" + + def bad_callback(): + raise ValueError("callback boom") + + result = executor.callback_function("_r3_trigger_ok", bad_callback) + # A callback failure must NOT discard the already-computed trigger result. + assert result == "TRIGGER_OK" + + +def test_callback_trigger_framework_exception_returns_none(): + from je_auto_control.utils.callback.callback_function_executor import ( + CallbackFunctionExecutor, + ) + executor = CallbackFunctionExecutor() + + def boom(**_kw): + raise AutoControlMouseException("mouse fail") + + executor.event_dict["_r3_trigger_bad"] = boom + ran = [] + result = executor.callback_function( + "_r3_trigger_bad", lambda: ran.append(1)) + # Framework trigger failure is handled (not propagated) and returns None; + # the callback does not run when the trigger failed. + assert result is None + assert ran == [] + + +# --- Finding 5: test-suite runner ---------------------------------------- + +class _FakeExecutor: + """Minimal executor stand-in for run_suite.""" + + def __init__(self, on_execute=None): + self.variables = types.SimpleNamespace(set=lambda *_a, **_k: None) + self.executed = [] + self._on_execute = on_execute + + def execute_action(self, actions, raise_on_error=False): + self.executed.append(actions) + if self._on_execute is not None: + return self._on_execute(actions, raise_on_error) + return {} + + +def test_run_actions_scores_lookup_error_as_error(): + from je_auto_control.utils.test_suite.runner import run_suite + from je_auto_control.utils.test_suite.result import STATUS_ERROR + + def raise_key_error(_actions, _raise): + raise KeyError("missing arg") # LookupError, outside the old tuple + + spec = {"name": "s", "cases": [{"name": "c1", "actions": [["x"]]}]} + result = run_suite(spec, executor=_FakeExecutor(raise_key_error)) + assert len(result.cases) == 1 + assert result.cases[0].status == STATUS_ERROR + + +def test_bad_case_does_not_abort_suite_and_teardown_runs(): + from je_auto_control.utils.test_suite.runner import run_suite + from je_auto_control.utils.test_suite.result import ( + STATUS_ERROR, STATUS_PASSED, + ) + spec = { + "name": "s", + "cases": [ + {"name": "bad", + "data": {"kind": "csv", "path": "/no/such/dir/missing.csv"}, + "actions": [["x"]]}, + {"name": "good", "actions": []}, + ], + "teardown": [["td"]], + } + executor = _FakeExecutor() + result = run_suite(spec, executor=executor) + by_name = {case.name: case.status for case in result.cases} + # The malformed (unexpandable) case scores error but does not kill the run. + assert by_name["bad"] == STATUS_ERROR + assert by_name["good"] == STATUS_PASSED + # Teardown always runs, even though a case blew up during expansion. + assert [["td"]] in executor.executed + + +# --- Finding 6: self-healing replay -------------------------------------- + +def test_self_healing_replay_contains_framework_exception(): + from je_auto_control.utils.semantic_recording.self_healing import ( + SelfHealingReplayer, + ) + + def execute(_action): + raise AutoControlActionException("boom") + + replayer = SelfHealingReplayer(execute, max_retries=1) + result = replayer.replay([{"action": "mouse_click", "x": 1, "y": 2}]) + assert result.succeeded is False + assert result.steps[0].success is False + assert "AutoControlActionException" in (result.steps[0].last_error or "") + + +def test_enrich_recording_passes_through_non_mapping(): + from je_auto_control.utils.semantic_recording.enrich import enrich_recording + actions = [{"action": "key_press", "key": "a"}, "not-a-mapping", 42] + out = enrich_recording(actions) + assert out[1] == "not-a-mapping" + assert out[2] == 42 + + +if __name__ == "__main__": + pytest.main([__file__, "-q"]) diff --git a/test/unit_test/headless/test_r3_util_json_and_sql.py b/test/unit_test/headless/test_r3_util_json_and_sql.py new file mode 100644 index 00000000..4f10a9c4 --- /dev/null +++ b/test/unit_test/headless/test_r3_util_json_and_sql.py @@ -0,0 +1,102 @@ +"""Round-3 audit regressions: JSON persistence + SQL URI fixes. + +Covers findings 3 (atomic JSON writes), 4 (cyclic ``$ref`` detection), and +11 (SQLite URI with special characters in the path). Headless: pure temp +files and an in-process SQLite DB, no real input. +""" +import os +import sqlite3 + +import pytest + + +# --- Finding 3: atomic writes -------------------------------------------- + +def _raise_oserror(_src, _dst): + raise OSError("simulated crash mid-replace") + + +def test_write_action_json_is_atomic_on_crash(tmp_path, monkeypatch): + from je_auto_control.utils.json.json_file import ( + read_action_json, write_action_json, + ) + from je_auto_control.utils.exception.exceptions import ( + AutoControlJsonActionException, + ) + target = tmp_path / "actions.json" + write_action_json(str(target), [["old"]]) + assert read_action_json(str(target)) == [["old"]] + + monkeypatch.setattr(os, "replace", _raise_oserror) + with pytest.raises(AutoControlJsonActionException): + write_action_json(str(target), [["new"]]) + monkeypatch.undo() + + # Old content survives the interrupted write; no temp leftovers remain. + assert read_action_json(str(target)) == [["old"]] + leftovers = [p.name for p in tmp_path.iterdir() if p.name != "actions.json"] + assert leftovers == [] + + +def test_write_json_dict_is_atomic_on_crash(tmp_path, monkeypatch): + from je_auto_control.utils.json_store.json_store import ( + read_json_dict, write_json_dict, + ) + target = tmp_path / "state.json" + write_json_dict(target, {"v": 1}) + assert read_json_dict(target) == {"v": 1} + + monkeypatch.setattr(os, "replace", _raise_oserror) + with pytest.raises(OSError): + write_json_dict(target, {"v": 2}) + monkeypatch.undo() + + assert read_json_dict(target) == {"v": 1} + leftovers = [p.name for p in tmp_path.iterdir() if p.name != "state.json"] + assert leftovers == [] + + +# --- Finding 4: cyclic $ref ------------------------------------------------ + +def test_validate_json_cyclic_ref_reports_clean_error(): + from je_auto_control.utils.json_schema.json_schema import validate_json + result = validate_json(1, {"$ref": "#"}) + assert result.ok is False + assert any(err["keyword"] == "$ref" for err in result.errors) + + +def test_validate_json_indirect_ref_cycle(): + from je_auto_control.utils.json_schema.json_schema import validate_json + schema = { + "$defs": { + "a": {"$ref": "#/$defs/b"}, + "b": {"$ref": "#/$defs/a"}, + }, + "$ref": "#/$defs/a", + } + result = validate_json(1, schema) + assert result.ok is False + assert any(err["keyword"] == "$ref" for err in result.errors) + + +# --- Finding 11: SQLite URI with special path characters ----------------- + +def test_query_sqlite_special_char_path(tmp_path): + from je_auto_control.utils.sql.sql_query import query_sqlite + # '%41' would percent-decode to 'A' in a raw file: URI and '#' is unsafe too. + db_path = tmp_path / "weird%41name#.db" + connection = sqlite3.connect(str(db_path)) + try: + connection.execute("CREATE TABLE t (id INTEGER, name TEXT)") + connection.execute("INSERT INTO t VALUES (1, 'alice')") + connection.commit() + finally: + connection.close() + + rows = query_sqlite(str(db_path), "SELECT name FROM t WHERE id = ?", + params=[1]) + assert rows == [{"name": "alice"}] + + +if __name__ == "__main__": + pytest.main([__file__, "-q"]) diff --git a/test/unit_test/headless/test_r3_util_misc.py b/test/unit_test/headless/test_r3_util_misc.py new file mode 100644 index 00000000..e4f54d0e --- /dev/null +++ b/test/unit_test/headless/test_r3_util_misc.py @@ -0,0 +1,129 @@ +"""Round-3 audit regressions: plugin isolation, dedup, time-travel, BDD. + +Covers findings 7 (plugin_sdk), 10 (dedup_window), 13 (time-travel action +index), and 14 (behave step registration). Fully headless. +""" +import sys +import types + +import pytest + + +# --- Finding 7: plugin discovery skips a broken plugin ------------------- + +class _FakeEntryPoint: + def __init__(self, name, factory): + self.name = name + self._factory = factory + + def load(self): + return self._factory + + +def test_discover_plugins_skips_broken_plugin(): + from je_auto_control.utils.plugin_sdk.plugin_sdk import discover_plugins + + def good_factory(): + return {"AC_good": lambda: "ok"} + + def key_error_factory(): + raise KeyError("boom") # outside the old catch tuple + + class _SyntaxErrorEP: + name = "syntax" + + def load(self): + raise SyntaxError("broken plugin module") + + points = [ + _FakeEntryPoint("bad", key_error_factory), + _SyntaxErrorEP(), + _FakeEntryPoint("good", good_factory), + ] + commands = discover_plugins(entry_points=points) + assert "AC_good" in commands + assert "bad" not in commands + + +# --- Finding 10: dedup window coerces int ids consistently --------------- + +def test_dedup_window_dedupes_integer_ids(): + from je_auto_control.utils.dedup_window.dedup_window import DedupWindow + clock = [1000.0] + window = DedupWindow(60.0, clock=lambda: clock[0]) + window.mark(123) + assert window.seen(123) is True + assert window.check_and_mark(123) is False + + +def test_dedup_window_check_and_mark_int_first_seen(): + from je_auto_control.utils.dedup_window.dedup_window import DedupWindow + clock = [1000.0] + window = DedupWindow(60.0, clock=lambda: clock[0]) + assert window.check_and_mark(7) is True + assert window.seen(7) is True + assert window.check_and_mark(7) is False + + +# --- Finding 13: actions-only recording still yields an action index ----- + +def test_actions_only_recording_yields_action_index(tmp_path): + from je_auto_control.utils.time_travel.controller import ( + TraceReplayController, + ) + from je_auto_control.utils.time_travel.player import ( + ActionEvent, TimelinePlayer, save_action_log, + ) + events = [ + ActionEvent(timestamp=1000.0, action_name="mouse_click", args={"x": 1}), + ActionEvent(timestamp=1001.5, action_name="type_keyboard", + args={"text": "a"}), + ] + save_action_log(events, tmp_path / "actions.jsonl") + + player = TimelinePlayer(tmp_path) # no manifest.json → total_steps == 0 + assert player.frame_count == 0 + assert player.action_count == 2 + + controller = TraceReplayController(player) + index = controller.action_index() + assert len(index) == 2 + + +# --- Finding 14: behave steps accept a leading context param ------------- + +def test_behave_wrap_forwards_params_and_accepts_context(): + from je_auto_control.utils.pytest_plugin.bdd_steps import _behave_wrap + seen = [] + wrapped = _behave_wrap(lambda text: seen.append(text) or "ok") + result = wrapped(object(), text="hello") + assert seen == ["hello"] + assert result == "ok" + + +def test_register_behave_steps_wrappers_accept_context(monkeypatch): + from je_auto_control.utils.pytest_plugin.bdd_steps import ( + register_behave_steps, + ) + registered = {} + + def factory(pattern): + def decorator(func): + registered[pattern] = func + return func + return decorator + + fake_behave = types.ModuleType("behave") + fake_behave.given = factory + fake_behave.when = factory + fake_behave.then = factory + monkeypatch.setitem(sys.modules, "behave", fake_behave) + + register_behave_steps() + ready_step = registered["AutoControl is ready"] + # behave calls func(context); the raw 0-arg step would raise TypeError. + ready_step(object()) + + +if __name__ == "__main__": + pytest.main([__file__, "-q"]) diff --git a/test/unit_test/headless/test_r3_util_win32_and_shell.py b/test/unit_test/headless/test_r3_util_win32_and_shell.py new file mode 100644 index 00000000..a6fc55ca --- /dev/null +++ b/test/unit_test/headless/test_r3_util_win32_and_shell.py @@ -0,0 +1,128 @@ +"""Round-3 audit regressions: process launch, WM_DROPFILES, shell reaping. + +Covers findings 8 (start_exe argv), 9 (file_drop ctypes signatures), and +12 (ShellManager.exit_program reaping). Headless: no real process is +launched and no real window message is posted. +""" +import subprocess + +import pytest + + +# --- Finding 8: start_exe must not shlex-split a single spaced path ------- + +def test_start_exe_passes_argv_list_not_split_string(tmp_path, monkeypatch): + from je_auto_control.utils.shell_process.shell_exec import ShellManager + from je_auto_control.utils.start_exe.start_another_process import start_exe + + exe = tmp_path / "my program.exe" + exe.write_text("stub") + captured = {} + + def fake_exec(self, command): + captured["command"] = command + self.process = object() # non-None → launch "succeeded" + + monkeypatch.setattr(ShellManager, "exec_shell", fake_exec) + start_exe(str(exe)) + # The spaced path is handed over verbatim as a single argv element. + assert captured["command"] == [str(exe)] + + +def test_start_exe_raises_when_launch_fails(tmp_path, monkeypatch): + from je_auto_control.utils.shell_process.shell_exec import ShellManager + from je_auto_control.utils.start_exe.start_another_process import start_exe + from je_auto_control.utils.exception.exceptions import AutoControlException + + exe = tmp_path / "app.exe" + exe.write_text("stub") + + def fake_exec(self, _command): + self.process = None # exec_shell swallowed the launch failure + + monkeypatch.setattr(ShellManager, "exec_shell", fake_exec) + with pytest.raises(AutoControlException): + start_exe(str(exe)) + + +# --- Finding 9: file_drop ctypes signatures ------------------------------ + +class _FakeFn: + """Stands in for a ctypes function pointer (accepts argtypes/restype).""" + + +class _FakeLib: + def __init__(self, names): + for name in names: + setattr(self, name, _FakeFn()) + + +def test_declare_win32_signatures_sets_all_argtypes(): + import ctypes + from ctypes import wintypes + from je_auto_control.utils.file_drop.file_drop import ( + _declare_win32_signatures, + ) + kernel32 = _FakeLib(["GlobalAlloc", "GlobalLock", "GlobalUnlock"]) + user32 = _FakeLib(["PostMessageW"]) + + _declare_win32_signatures(kernel32, user32) + + assert kernel32.GlobalAlloc.argtypes == [wintypes.UINT, ctypes.c_size_t] + assert kernel32.GlobalAlloc.restype is wintypes.HGLOBAL + # These three previously had NO argtypes → 64-bit handle truncation. + assert kernel32.GlobalLock.argtypes == [wintypes.HGLOBAL] + assert kernel32.GlobalLock.restype is ctypes.c_void_p + assert kernel32.GlobalUnlock.argtypes == [wintypes.HGLOBAL] + assert kernel32.GlobalUnlock.restype is wintypes.BOOL + assert user32.PostMessageW.argtypes == [ + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, + ] + assert user32.PostMessageW.restype is wintypes.BOOL + + +# --- Finding 12: exit_program reaps and logs the real exit code ---------- + +class _FakeProc: + def __init__(self): + self.returncode = None + self.terminated = False + self.waited = False + + def terminate(self): + self.terminated = True + + def wait(self, timeout=None): # noqa: ARG002 # reason: mirrors Popen.wait + self.waited = True + self.returncode = 0 + return 0 + + def poll(self): + return self.returncode + + +def test_exit_program_waits_and_logs_real_returncode(monkeypatch): + from je_auto_control.utils.shell_process import shell_exec as shell_mod + from je_auto_control.utils.shell_process.shell_exec import ShellManager + + manager = ShellManager() + proc = _FakeProc() + manager.process = proc + messages = [] + monkeypatch.setattr( + shell_mod.autocontrol_logger, "info", + lambda msg, *a, **k: messages.append(str(msg))) + monkeypatch.setattr( + shell_mod.autocontrol_logger, "error", lambda *a, **k: None) + + manager.exit_program() + + assert proc.terminated is True + assert proc.waited is True # exit code was actually reaped, not read as None + assert any("code 0" in message for message in messages) + assert manager.process is None + assert isinstance(subprocess.TimeoutExpired, type) # import sanity + + +if __name__ == "__main__": + pytest.main([__file__, "-q"]) diff --git a/test/unit_test/headless/test_r3_vision_capture.py b/test/unit_test/headless/test_r3_vision_capture.py new file mode 100644 index 00000000..8f157f4a --- /dev/null +++ b/test/unit_test/headless/test_r3_vision_capture.py @@ -0,0 +1,132 @@ +"""R3 regression: recorder stop, frame-diff pixel counting, screenshot save. + +Covers: +* ScreenRecordThread ignoring a stop() that lands before run() (run() set the + flag True unconditionally), leaving it unstoppable and the writer un-released. +* smart_waits._frame_diff counting differing BYTES instead of PIXELS, so a + one-pixel blink read as three and wait_until_screen_stable(max_pixel_diff=2) + never settled. +* pil_screenshot swallowing a save failure and returning the image anyway, so a + requested screenshot file could silently not exist. +""" +import pytest + +np = pytest.importorskip("numpy") +pytest.importorskip("cv2") + +from je_auto_control.utils.cv2_utils import screen_record as sr # noqa: E402 +from je_auto_control.utils.cv2_utils import screenshot as ss # noqa: E402 +from je_auto_control.utils.smart_waits import waits # noqa: E402 +from je_auto_control.utils.exception.exceptions import ( # noqa: E402 + AutoControlScreenException, +) + + +class _FakeVideoWriter: + """Stand-in for cv2.VideoWriter that never touches a real file.""" + + @staticmethod + def fourcc(*_codec): + return 0 + + def __init__(self, *_args, **_kwargs): + self.writes = 0 + self.released = False + + def write(self, _frame): + self.writes += 1 + + def release(self): + self.released = True + + +# --- finding 2: recorder stop honoured + writer released ----------------- + +def test_stop_before_run_is_honored(monkeypatch): + monkeypatch.setattr(sr.cv2, "VideoWriter", _FakeVideoWriter) + thread = sr.ScreenRecordThread("out.avi", "XVID", 30, (4, 4)) + frame = np.zeros((4, 4, 3), dtype=np.uint8) + + def screenshot_bounds_the_buggy_path(): + # If the pre-run stop() is overwritten (old bug), stopping here bounds + # the loop to a single frame so the test cannot hang; the assertions + # below still detect that the loop body ran. + thread.stop() + return frame + + monkeypatch.setattr(sr, "screenshot", screenshot_bounds_the_buggy_path) + + thread.stop() # stop BEFORE the thread body runs + thread.run() # run synchronously + + assert thread.video_writer.writes == 0 # loop body never executed + assert thread.video_writer.released is True # writer always released + + +def test_normal_run_writes_frames_and_releases(monkeypatch): + monkeypatch.setattr(sr.cv2, "VideoWriter", _FakeVideoWriter) + thread = sr.ScreenRecordThread("out.avi", "XVID", 30, (4, 4)) + frame = np.zeros((4, 4, 3), dtype=np.uint8) + calls = {"n": 0} + + def screenshot_stop_after_two(): + calls["n"] += 1 + if calls["n"] >= 2: + thread.stop() + return frame + + monkeypatch.setattr(sr, "screenshot", screenshot_stop_after_two) + thread.run() + + assert thread.video_writer.writes == 2 + assert thread.video_writer.released is True + + +# --- finding 4: frame diff counts pixels, not bytes ---------------------- + +def test_frame_diff_counts_pixels_not_bytes(): + base = bytes([0, 0, 0] * 4) # 2x2 RGB, all black + changed = bytearray(base) + changed[0:3] = bytes([255, 255, 255]) # one pixel, three differing bytes + a = waits.Frame(2, 2, base) + b = waits.Frame(2, 2, bytes(changed)) + assert waits._frame_diff(a, b) == 1 # one pixel, not three bytes + + +def test_screen_stable_settles_on_one_pixel_blink(): + base = bytes([10, 20, 30] * 4) + blink = bytearray(base) + blink[0:3] = bytes([200, 200, 200]) # a one-pixel change + frames = [base, bytes(blink)] + state = {"i": 0} + + def sampler(_region): + # Alternate base <-> blink forever: consecutive frames always differ by + # exactly one pixel, so a pixel-aware diff (<=2) settles immediately + # while the old byte-aware diff (=3) never would. + frame = frames[state["i"] % 2] + state["i"] += 1 + return waits.Frame(2, 2, frame) + + outcome = waits.wait_until_screen_stable( + timeout_s=0.2, poll_interval_s=0.001, stable_for_s=0.0, + max_pixel_diff=2, sampler=sampler, + ) + assert outcome.succeeded is True + + +# --- finding 7: screenshot save failure raises --------------------------- + +class _FakeGrab: + @staticmethod + def grab(*_args, **_kwargs): + from PIL import Image + return Image.new("RGB", (4, 4)) + + +def test_screenshot_save_failure_raises(monkeypatch, tmp_path): + pytest.importorskip("PIL") + monkeypatch.setattr(ss, "ImageGrab", _FakeGrab) + bad_path = str(tmp_path / "no_such_dir" / "shot.png") # parent missing + with pytest.raises(AutoControlScreenException): + ss.pil_screenshot(file_path=bad_path) diff --git a/test/unit_test/headless/test_r3_vision_gray.py b/test/unit_test/headless/test_r3_vision_gray.py new file mode 100644 index 00000000..3bb8bed2 --- /dev/null +++ b/test/unit_test/headless/test_r3_vision_gray.py @@ -0,0 +1,92 @@ +"""R3 regression: RGB vs BGR luminance weights + bounded find-all. No Qt. + +Covers: +* visual_match._to_gray / ssim._to_gray_f converting ndarray / PIL (RGB) sources + with BGR weights, which swapped the R/B luminance weights so a red template's + gray disagreed with the same red on screen by up to ~47/255. +* best_matches materialising a Match per score-map position (min_score=-1.0), + effectively hanging on a real screen. +""" +import pytest + +np = pytest.importorskip("numpy") +cv2 = pytest.importorskip("cv2") + +from je_auto_control.utils.visual_match import visual_match as vm # noqa: E402 +from je_auto_control.utils.ssim import ssim as ssim_mod # noqa: E402 +from je_auto_control.utils.ssim.ssim import ssim_compare # noqa: E402 + + +# Correct luminance of saturated red: 0.299 * 255. The old BGR-on-RGB path gave +# 0.114 * 255 ~= 29 (the blue weight). +_RED_GRAY = 0.299 * 255 + + +def _rgb_red(height: int = 8, width: int = 8): + """A saturated-red image in RGB channel order (R in channel 0).""" + img = np.zeros((height, width, 3), dtype=np.uint8) + img[..., 0] = 255 + return img + + +def test_visual_match_gray_uses_rgb_weights_for_ndarray(): + gray = vm._to_gray(_rgb_red()) + assert gray.mean() == pytest.approx(_RED_GRAY, abs=1.0) + + +def test_visual_match_gray_uses_rgb_weights_for_pil(): + pytest.importorskip("PIL") + from PIL import Image + gray = vm._to_gray(Image.fromarray(_rgb_red())) # PIL is RGB + assert gray.mean() == pytest.approx(_RED_GRAY, abs=1.0) + + +def test_disk_bgr_template_and_rgb_haystack_agree(tmp_path): + # A red image written to disk is read back as BGR by cv2.imread; the same + # red as an RGB ndarray (the live screen grab) must map to the same gray. + path = tmp_path / "red.png" + cv2.imwrite(str(path), np.full((8, 8, 3), (0, 0, 255), np.uint8)) # BGR red + from_disk = vm._to_gray(str(path)) + from_rgb = vm._to_gray(_rgb_red()) + assert from_disk.mean() == pytest.approx(from_rgb.mean(), abs=1.0) + assert from_disk.mean() == pytest.approx(_RED_GRAY, abs=1.0) + + +def test_ssim_gray_uses_rgb_weights_for_ndarray(): + gray = ssim_mod._to_gray_f(_rgb_red()) + assert float(gray.mean()) == pytest.approx(_RED_GRAY, abs=1.0) + + +def test_ssim_compare_disk_red_vs_rgb_red_is_identical(tmp_path): + path = tmp_path / "red.png" + cv2.imwrite(str(path), np.full((20, 20, 3), (0, 0, 255), np.uint8)) + score = ssim_compare(str(path), _rgb_red(20, 20)) + # Structurally identical red -> ~1.0. The old code read them ~47 apart, + # scoring the identical colour as a large structural change (~0.67). + assert score > 0.99 + + +def _textured(height: int, width: int, seed: int = 0): + yy, xx = np.mgrid[0:height, 0:width] + return ((yy * 53 + xx * 97 + yy * xx * 17 + seed) % 256).astype(np.uint8) + + +def test_best_matches_is_bounded(monkeypatch): + """best_matches must cap candidates before the O(n·kept) Python NMS.""" + hay = _textured(200, 300, seed=5) + tmpl = hay[40:52, 60:72].copy() # an exact 12x12 sub-patch + + built = {"n": 0} + real_match = vm.Match + + def counting_match(*args, **kwargs): + built["n"] += 1 + return real_match(*args, **kwargs) + + monkeypatch.setattr(vm, "Match", counting_match) + result = vm.best_matches(tmpl, haystack=hay, top_n=5) + + assert len(result) <= 5 + assert (result[0].x, result[0].y) == (60, 40) # correctness preserved + # ~54k positions clear min_score=-1.0; the cap keeps construction bounded. + assert built["n"] <= 1000 diff --git a/test/unit_test/headless/test_r3_vision_locator.py b/test/unit_test/headless/test_r3_vision_locator.py new file mode 100644 index 00000000..a12af5bd --- /dev/null +++ b/test/unit_test/headless/test_r3_vision_locator.py @@ -0,0 +1,77 @@ +"""R3 regression: anchor not-found handling, directional distance, wma zero-sum. + +Covers: +* anchor adapters catching only (OSError, RuntimeError, ValueError), so a plain + ImageNotFoundException / AutoControlActionException ("not on screen") crashed + the whole locate instead of returning found=False / []. +* max_distance_px applied only for relation==near, ignored for directional + relations. +* smoothing.wma dividing by a zero-sum weight window (warm-up tail of [1, 0]). +""" +import pytest + +from je_auto_control.utils.anchor_locator import ( + anchor_locate, image_locator, ocr_locator, REL_NEAR, +) +from je_auto_control.utils.anchor_locator import locator as locator_mod +from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException, ImageNotFoundException, +) +from je_auto_control.utils.smoothing.smoothing import wma + + +# --- finding 8: adapters swallow framework "not found" exceptions -------- + +def test_image_candidates_swallow_not_found(monkeypatch): + def raise_not_found(*_args, **_kwargs): + raise ImageNotFoundException("not on screen") + + monkeypatch.setattr( + "je_auto_control.wrapper.auto_control_image.locate_all_image", + raise_not_found, + ) + assert locator_mod._image_candidates(image_locator("b.png")) == [] + + +def test_ocr_center_swallows_action_exception(monkeypatch): + def raise_action(*_args, **_kwargs): + raise AutoControlActionException("text not found") + + monkeypatch.setattr( + "je_auto_control.utils.ocr.ocr_engine.locate_text_center", + raise_action, + ) + assert locator_mod._ocr_center(ocr_locator("Submit")) is None + + +def test_anchor_locate_target_missing_returns_outcome(monkeypatch): + monkeypatch.setattr( + "je_auto_control.wrapper.auto_control_image.locate_image_center", + lambda *_a, **_k: (10, 10), # anchor resolves fine + ) + + def raise_not_found(*_args, **_kwargs): + raise ImageNotFoundException("no target") + + monkeypatch.setattr( + "je_auto_control.wrapper.auto_control_image.locate_all_image", + raise_not_found, + ) + outcome = anchor_locate(anchor=image_locator("a.png"), + target=image_locator("b.png"), relation=REL_NEAR) + assert outcome.found is False + assert outcome.error == "target not found" + + +# --- finding 9: wma zero-sum weight window ------------------------------- + +def test_wma_zero_weight_tail_no_zero_division(): + # weights=[1, 0]: the warm-up window is [value0] with applied weight [0]. + assert wma([5.0, 6.0], weights=[1, 0]) == [5.0, 5.0] + + +def test_wma_normal_case_unaffected(): + out = wma([1.0, 2.0, 3.0], weights=[1, 2]) + assert out[0] == pytest.approx(1.0) + assert out[1] == pytest.approx((1 * 1 + 2 * 2) / 3) + assert out[2] == pytest.approx((2 * 1 + 3 * 2) / 3) diff --git a/test/unit_test/headless/test_r3_vision_poll.py b/test/unit_test/headless/test_r3_vision_poll.py new file mode 100644 index 00000000..2e827ea6 --- /dev/null +++ b/test/unit_test/headless/test_r3_vision_poll.py @@ -0,0 +1,43 @@ +"""R3 regression: expect_poll clamps its sleep to the remaining time. No Qt. + +A large interval_s could otherwise overshoot timeout_s by nearly a full +interval (and run an extra getter well past the deadline). +""" +from je_auto_control.utils.expect_poll import expect_poll, to_equal + + +def test_sleep_is_clamped_to_remaining_time(): + times = [0.0] + sleeps = [] + + def clock(): + return times[0] + + def sleep(seconds): + sleeps.append(seconds) + times[0] += seconds + + result = expect_poll(lambda: 1, to_equal(2), timeout_s=0.05, + interval_s=100.0, clock=clock, sleep=sleep) + + assert result.ok is False + # No single sleep may exceed the time left, and the poll must not overshoot + # the deadline. The old code slept the full 100s on the first miss. + assert sleeps and max(sleeps) <= 0.05 + 1e-9 + assert times[0] <= 0.05 + 1e-9 + + +def test_clamp_does_not_change_evenly_divided_timeline(): + # interval_s divides timeout_s evenly: clamping must not change attempts. + times = [0.0] + + def clock(): + return times[0] + + def sleep(seconds): + times[0] += seconds + + result = expect_poll(lambda: 0, to_equal(9), timeout_s=5.0, + interval_s=0.25, clock=clock, sleep=sleep) + assert result.ok is False + assert result.attempts == 21 # 20 sleeps + the first attempt diff --git a/test/unit_test/headless/test_remote_desktop_host_lifecycle.py b/test/unit_test/headless/test_remote_desktop_host_lifecycle.py new file mode 100644 index 00000000..6c4c80fc --- /dev/null +++ b/test/unit_test/headless/test_remote_desktop_host_lifecycle.py @@ -0,0 +1,135 @@ +"""Lifecycle races on RemoteDesktopHost. + +The class docstring promises "start() is idempotent and stop() can be called +from any thread". Nothing enforced that, and for a tool that hands a remote +peer control of the local mouse and keyboard, a stop() that does not actually +stop is a safety problem rather than a tidiness one. +""" +import socket +import threading +import time + +import pytest + +from je_auto_control.utils.remote_desktop import host as host_mod +from je_auto_control.utils.remote_desktop.host import RemoteDesktopHost + + +def _make_host() -> RemoteDesktopHost: + return RemoteDesktopHost(token="t", bind="127.0.0.1", port=0, + frame_provider=lambda: None) + + +class _FakeChannel: + """Stands in for a completed handshake.""" + + def close(self): + pass + + def settimeout(self, *_a): + pass + + def send(self, *_a, **_k): + pass + + def recv(self, *_a, **_k): + time.sleep(9) + raise OSError("closed") + + +def test_a_client_finishing_its_handshake_after_stop_is_not_attached( + monkeypatch): + """A viewer must never attach to a host the operator already stopped. + + Regression: _open_channel performs the auth/TLS handshake (bounded by + _AUTH_TIMEOUT_S = 60s). stop() sets _shutdown, then snapshots and clears + _clients under _clients_lock. _accept_loop appended the finished handler + without re-checking _shutdown under that same lock, so a client landing in + that window was never in stop()'s snapshot and was never stopped — its + receiver thread kept dispatching remote INPUT to the local machine after + stop() returned and is_running was already False. + """ + parked, release = threading.Event(), threading.Event() + + def slow_open_channel(_self, _sock, _address): + parked.set() + release.wait(5.0) + return _FakeChannel() + + monkeypatch.setattr(RemoteDesktopHost, "_open_channel", slow_open_channel) + monkeypatch.setattr(host_mod._ClientHandler, "start", lambda self: None) + + host = _make_host() + host.start() + conn = socket.create_connection(("127.0.0.1", host.port), timeout=3) + try: + assert parked.wait(3.0), "accept thread never reached the handshake" + host.stop(timeout=1.0) + assert host.is_running is False + + release.set() + time.sleep(0.8) # let the accept thread finish its handshake + + assert host._clients == [], ( + "a client attached to a stopped host: it would keep applying " + "remote input locally" + ) + finally: + release.set() + try: + conn.close() + except OSError: + pass + host.stop(timeout=1.0) + + +def test_stop_racing_start_never_raises_or_leaks_a_thread_exception(): + """start() and stop() must be mutually exclusive. + + Three distinct regressions lived here, all reproducible only under a + concurrent stop(): + * AttributeError — stop() nulled _capture_thread between start()'s + assignment and its .start() call; + * RuntimeError('cannot join thread before it is started') — stop()'s + guard (_listen_sock) was already set, so it joined unstarted threads; + * OSError WinError 10038 — the accept thread called settimeout() on the + listening socket outside any try, racing stop() closing it. + + This is a stress test, so it is probabilistic by nature: against the + unfixed code these surfaced at roughly 3/300 and 1/300, and 120 rounds was + not enough to catch them. 400 keeps it reliable while still finishing in a + couple of seconds. The two tests around it are deterministic. + """ + caught: list = [] + original = threading.excepthook + threading.excepthook = lambda args: caught.append( + (args.thread.name, type(args.exc_value).__name__)) + raised: list = [] + try: + for _ in range(400): + host = _make_host() + starter = threading.Thread(target=host.start) + starter.start() + for _ in range(2): + try: + host.stop(timeout=0.2) + except (RuntimeError, AttributeError) as error: + raised.append(type(error).__name__) + starter.join(2.0) + host.stop(timeout=0.2) + finally: + threading.excepthook = original + + assert raised == [], f"stop() raised: {raised[:3]}" + assert caught == [], f"thread died: {caught[:3]}" + + +def test_listening_socket_timeout_is_set_before_the_thread_can_see_it(): + """The accept thread must not configure a socket stop() may close.""" + host = _make_host() + host.start() + try: + assert host._listen_sock.gettimeout() == pytest.approx( + host_mod._ACCEPT_POLL_TIMEOUT_S) + finally: + host.stop(timeout=1.0) diff --git a/test/unit_test/headless/test_remote_desktop_relay.py b/test/unit_test/headless/test_remote_desktop_relay.py index 22bd4f9d..102c0b4d 100644 --- a/test/unit_test/headless/test_remote_desktop_relay.py +++ b/test/unit_test/headless/test_remote_desktop_relay.py @@ -1,5 +1,6 @@ """Phase 3.3: tests for the in-process TCP relay.""" import socket +import threading import time import pytest @@ -118,3 +119,27 @@ def test_relay_stops_cleanly(): assert server.is_running server.stop(timeout=1.0) assert not server.is_running + + +def test_rapid_start_stop_never_leaks_a_thread_exception(monkeypatch): + """Stopping must not blow up the accept thread. + + Regression: start() published the listening socket and left the *new* + thread to call settimeout() on it. A stop() landing in that window closed + the socket first, so settimeout raised WSAENOTSOCK (WinError 10038) outside + any handler and the accept thread died with an unhandled exception — + reproducibly, on ~45% of tight start/stop cycles. + + test_relay_stops_cleanly above cannot catch this: it only asserts the + is_running flags, which are correct whether or not the thread survived. + """ + caught: list = [] + monkeypatch.setattr(threading, "excepthook", + lambda args: caught.append(args.exc_value)) + + for _ in range(50): + server = RelayServer(bind="127.0.0.1", port=0) + server.start() + server.stop(timeout=1.0) # no sleep: keep the race window wide + + assert caught == [], f"accept thread raised: {caught[:3]}" diff --git a/test/unit_test/headless/test_script_builder_param_preservation.py b/test/unit_test/headless/test_script_builder_param_preservation.py new file mode 100644 index 00000000..3d0fd023 --- /dev/null +++ b/test/unit_test/headless/test_script_builder_param_preservation.py @@ -0,0 +1,86 @@ +"""Loading a step into the form must never lose the user's data. + +Script Builder round-trips real files: builder_tab loads an action JSON, and on +save writes the steps back out. So anything the form drops on load is written +straight back over the user's file. +""" +import pytest + +pytest.importorskip("PySide6.QtWidgets") # skips if Qt libs (e.g. libEGL) absent + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from je_auto_control.gui.script_builder.step_form_view import ( # noqa: E402 + StepFormView, +) +from je_auto_control.gui.script_builder.step_model import ( # noqa: E402 + action_to_step, step_to_action, +) + + +@pytest.fixture(scope="module") +def qapp(): + return QApplication.instance() or QApplication([]) + + +def _round_trip(action): + """Load an action into the form exactly as selecting a step does.""" + step = action_to_step(action) + view = StepFormView() + view.load_step(step) + return step_to_action(step) + + +@pytest.mark.parametrize("action", [ + # Params the schema has no field for — legitimate in hand-written JSON. + ["AC_debug_trace", {"actions": [["AC_click_mouse", + {"mouse_keycode": "mouse_left"}]], + "dry_run": True}], + ["AC_skill_save", {"actions": [["AC_ok"]], "path": "s.json", + "name": "old"}], + # A plain, fully-schema'd command must be untouched too. + ["AC_execute_process", {"exe_path": "notepad.exe"}], +]) +def test_loading_a_step_preserves_every_param(qapp, action): + """Regression: two independent faults conspired to lose data. + + 1. _commit_field rebuilt params from scratch out of spec.fields only, so + any param without a schema field was dropped. + 2. Editors are wired to textChanged at build time while + _populate_from_step fills them one at a time, so each setText committed + a half-filled form — clobbering fields not yet populated (this is what + turned AC_skill_save's "name" into None even though it *is* a field). + + Merely selecting the step was enough; builder_tab then saved the result. + """ + assert _round_trip(action) == action + + +def test_editing_a_field_keeps_unknown_params(qapp): + """A real edit must update its own field and leave the rest alone.""" + action = ["AC_debug_trace", {"actions": [["AC_ok"]], "dry_run": False}] + step = action_to_step(action) + view = StepFormView() + view.load_step(step) + + editor = view._editors["dry_run"] + editor.setChecked(True) # a genuine user edit, post-population + + out = step_to_action(step) + assert out[1]["dry_run"] is True + assert out[1]["actions"] == [["AC_ok"]], "unknown param lost on edit" + + +def test_clearing_an_optional_field_drops_the_key(qapp): + """The pre-existing semantic must survive: empty optional => no key.""" + action = ["AC_click_mouse", {"mouse_keycode": "mouse_left", + "x": 10, "y": 20}] + step = action_to_step(action) + view = StepFormView() + view.load_step(step) + + view._editors["x"].clear() # user empties an optional field + + out = step_to_action(step) + assert "x" not in out[1] + assert out[1]["y"] == 20 diff --git a/test/unit_test/headless/test_script_builder_schema_parity.py b/test/unit_test/headless/test_script_builder_schema_parity.py new file mode 100644 index 00000000..8c1f1f4d --- /dev/null +++ b/test/unit_test/headless/test_script_builder_schema_parity.py @@ -0,0 +1,80 @@ +"""Parity between the Script Builder schema and the executor's real signatures. + +The builder emits ``[command, params]`` and the executor dispatches +``event(**params)``. So every field the schema declares must be a parameter the +dispatch target actually accepts — a renamed or invented field is not a +cosmetic mismatch, it is a guaranteed TypeError the moment the step runs. + +Existing tests only assert command *names* exist in the schema, which is why +two such fields shipped: ``AC_click_mouse`` declared ``times`` (click_mouse has +no such parameter, and its default=1 was committed on load, so the most basic +command failed with no user input at all), and ``AC_execute_process`` declared +``program_path`` against ``start_exe(exe_path)``. +""" +import inspect + +import pytest + +from je_auto_control.gui.script_builder.command_schema import _build_specs +from je_auto_control.utils.executor.action_executor import executor + + +def _dispatch_targets(): + """Yield (command, spec, signature) for specs with an introspectable target. + + Block commands (AC_try, AC_loop, …) are dispatched as ``handler(executor, + args)`` rather than ``event(**params)``, so they are not in event_dict and + are out of scope here. + """ + for spec in _build_specs(): + target = executor.event_dict.get(spec.command) + if target is None: + continue + try: + sig = inspect.signature(target) + except (TypeError, ValueError): # builtins without signatures + continue + yield spec, sig + + +def _accepts_arbitrary_kwargs(sig) -> bool: + return any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values()) + + +def test_every_schema_field_is_accepted_by_its_dispatch_target(): + offenders = [] + for spec, sig in _dispatch_targets(): + if _accepts_arbitrary_kwargs(sig): + continue + accepted = set(sig.parameters) | set(spec.body_keys or ()) + for field in spec.fields: + if field.name not in accepted: + offenders.append( + f"{spec.command}: schema field {field.name!r} is not a " + f"parameter of {sig}" + ) + assert offenders == [], ( + "Script Builder would emit params the executor cannot accept:\n " + + "\n ".join(offenders) + ) + + +@pytest.mark.parametrize("command, field_name", [ + ("AC_click_mouse", "times"), + ("AC_execute_process", "program_path"), +]) +def test_previously_broken_fields_stay_gone(command, field_name): + """Pin the two specific regressions above by name.""" + spec = next(s for s in _build_specs() if s.command == command) + assert field_name not in {f.name for f in spec.fields} + + +def test_the_two_repaired_commands_bind_cleanly(): + """The end-to-end property that actually matters: params bind to the call.""" + click = executor.event_dict["AC_click_mouse"] + inspect.signature(click).bind(mouse_keycode="mouse_left", x=1, y=2) + + start = executor.event_dict["AC_execute_process"] + spec = next(s for s in _build_specs() if s.command == "AC_execute_process") + field = spec.fields[0].name + inspect.signature(start).bind(**{field: "notepad.exe"}) diff --git a/test/unit_test/headless/test_tls_acme.py b/test/unit_test/headless/test_tls_acme.py index 224f58f3..9de9220a 100644 --- a/test/unit_test/headless/test_tls_acme.py +++ b/test/unit_test/headless/test_tls_acme.py @@ -215,6 +215,32 @@ def boom(): assert isinstance(failures[0], RuntimeError) +def test_renewal_survives_a_certbot_subprocess_failure(tmp_path): + """certbot failures must route to on_failure, not kill the renewal thread. + + Regression: tick() caught only (RuntimeError, OSError, ValueError), but + run_certbot raises subprocess.CalledProcessError (exit != 0) and + TimeoutExpired — both subprocess.SubprocessError, none of them in the + tuple. The first certbot failure — the exact moment renewal matters — let + the exception escape tick(), killed the acme-renewal thread, never fired + on_failure, and the certificate silently expired. + """ + import subprocess + + failures = [] + + def certbot_fails(): + raise subprocess.CalledProcessError(1, ["certbot", "renew"]) + + scheduler = RenewalScheduler( + tmp_path / "cert.pem", renew=certbot_fails, + threshold=timedelta(days=30), on_failure=failures.append, + ) + assert scheduler.tick() is True # attempted, did not raise + assert len(failures) == 1 + assert isinstance(failures[0], subprocess.CalledProcessError) + + def test_scheduler_start_and_stop_are_idempotent(tmp_path): scheduler = RenewalScheduler( tmp_path / "cert.pem", diff --git a/test/unit_test/headless/test_trigger_engine.py b/test/unit_test/headless/test_trigger_engine.py index f4952833..89d9a0f1 100644 --- a/test/unit_test/headless/test_trigger_engine.py +++ b/test/unit_test/headless/test_trigger_engine.py @@ -149,6 +149,8 @@ def test_cron_trigger_skips_when_minute_mismatches(): def test_cron_trigger_rejects_invalid_expression(): - trig = CronTrigger(trigger_id="c", script_path="s.json", cron="not-cron") + # Rejected at construction, not deferred to is_fired(). Validating on the + # polling thread was the bug: the ValueError escaped _poll_once and killed + # the engine, taking every other trigger with it. with pytest.raises(ValueError): - trig.is_fired() + CronTrigger(trigger_id="c", script_path="s.json", cron="not-cron") diff --git a/test/unit_test/headless/test_trigger_engine_resilience.py b/test/unit_test/headless/test_trigger_engine_resilience.py new file mode 100644 index 00000000..dd445616 --- /dev/null +++ b/test/unit_test/headless/test_trigger_engine_resilience.py @@ -0,0 +1,107 @@ +"""One bad trigger must never stop the trigger engine. + +The engine polls every registered trigger on a single background thread. That +makes any escaping exception a poison pill: the thread dies, *every* trigger +silently stops firing, and the offender stays registered so a restart dies the +same way. Nothing logs a failure and the UI still lists the triggers as active. +""" +import threading +import time + +import pytest + +from je_auto_control.utils.triggers.trigger_engine import ( + CronTrigger, TriggerEngine, _TriggerBase, +) + + +class _RogueTrigger(_TriggerBase): + """is_fired() reaches out to the screen/clock/filesystem — it can raise.""" + + def is_fired(self) -> bool: + raise RuntimeError("boom") + + +@pytest.fixture() +def engine(): + made = TriggerEngine(executor=lambda actions: None, tick_seconds=0.05) + yield made + made.stop() + + +def test_a_bad_cron_expression_is_rejected_at_construction(): + """Regression: parse_cron ran on the polling thread, not the caller. + + The Triggers tab passes its cron QLineEdit text straight to add(), so a + typo used to register fine and then kill the engine on the next tick. + """ + with pytest.raises(ValueError, match="cron"): + CronTrigger(trigger_id="bad", script_path="b.json", cron="not a cron") + + +def test_a_valid_cron_still_constructs(): + trigger = CronTrigger(trigger_id="ok", script_path="s.json", + cron="*/5 * * * *") + assert trigger.cron == "*/5 * * * *" + + +def test_a_trigger_whose_is_fired_raises_is_disabled_not_fatal(engine): + """Regression: an exception from is_fired() killed the polling thread.""" + caught: list = [] + original = threading.excepthook + threading.excepthook = lambda args: caught.append( + type(args.exc_value).__name__) + try: + engine.add(_RogueTrigger(trigger_id="rogue", script_path="r.json", + repeat=True)) + engine.start() + time.sleep(0.4) + + assert engine._thread.is_alive(), "polling thread died" + assert caught == [] + assert engine._triggers["rogue"].enabled is False, ( + "the offender should be disabled so it stops re-raising each tick" + ) + finally: + threading.excepthook = original + + +def test_a_trigger_pointing_at_a_missing_script_is_not_fatal(engine): + """Regression: _fire caught only (OSError, ValueError, RuntimeError). + + read_action_json raises AutoControlJsonActionException, which is none of + those — so merely renaming a trigger's script file killed the engine. + """ + caught: list = [] + original = threading.excepthook + threading.excepthook = lambda args: caught.append( + type(args.exc_value).__name__) + try: + engine.add(CronTrigger(trigger_id="missing", + script_path="does-not-exist.json", + cron="* * * * *", repeat=True)) + engine.start() + time.sleep(0.4) + + assert engine._thread.is_alive(), "polling thread died" + assert caught == [] + finally: + threading.excepthook = original + + +def test_a_healthy_trigger_keeps_firing_alongside_a_broken_one(engine): + """The whole point: one poison pill must not take the others down.""" + fired: list = [] + engine._execute = lambda actions: fired.append(actions) + engine.add(_RogueTrigger(trigger_id="rogue", script_path="r.json", + repeat=True)) + healthy = CronTrigger(trigger_id="healthy", script_path="h.json", + cron="* * * * *", repeat=True) + engine.add(healthy) + + engine.start() + time.sleep(0.4) + + assert engine._thread.is_alive() + assert engine._triggers["rogue"].enabled is False + assert engine._triggers["healthy"].enabled is True diff --git a/test/unit_test/headless/test_usb_acl_prompt.py b/test/unit_test/headless/test_usb_acl_prompt.py index 61a61091..e11c537b 100644 --- a/test/unit_test/headless/test_usb_acl_prompt.py +++ b/test/unit_test/headless/test_usb_acl_prompt.py @@ -69,10 +69,23 @@ def test_dialog_remember_reflects_checkbox(qapp): # --------------------------------------------------------------------------- +# A retry chain must be bounded. An unbounded self-rescheduling singleShot +# outlives the test that started it: the qapp fixture below reuses +# QApplication.instance(), which in a full-suite run was created by an earlier +# module and lives for the whole session. An orphaned chain therefore keeps +# scanning topLevelWidgets() indefinitely and can press accept()/reject() on a +# later test's prompt. 2s of retries comfortably covers the 3s decide() +# timeout these tests use. +_DIALOG_RETRY_INTERVAL_MS = 20 +_DIALOG_MAX_ATTEMPTS = 100 + + def _drive_dialog_when_visible(action: str) -> None: - """Schedule a one-shot Qt timer that finds the modal dialog and + """Schedule a bounded Qt timer chain that finds the modal dialog and presses Allow / Deny / cancel on it. """ + remaining = {"attempts": _DIALOG_MAX_ATTEMPTS} + def attempt(): for widget in QApplication.topLevelWidgets(): if isinstance(widget, UsbPassthroughPromptDialog) and widget.isVisible(): @@ -86,8 +99,11 @@ def attempt(): else: widget.reject() return - # Try again shortly if the dialog hasn't appeared yet. - QTimer.singleShot(20, attempt) + # Try again shortly if the dialog hasn't appeared yet — but give up + # rather than outlive this test. + remaining["attempts"] -= 1 + if remaining["attempts"] > 0: + QTimer.singleShot(_DIALOG_RETRY_INTERVAL_MS, attempt) QTimer.singleShot(50, attempt) @@ -95,13 +111,17 @@ def _close_dialog_after(ms: int) -> None: """Reject the prompt once it is up — used by the timeout case so the GUI thread's modal loop unwinds after ``decide`` has already given up. """ + remaining = {"attempts": _DIALOG_MAX_ATTEMPTS} + def shut(): for widget in QApplication.topLevelWidgets(): if (isinstance(widget, UsbPassthroughPromptDialog) and widget.isVisible()): widget.reject() return - QTimer.singleShot(20, shut) + remaining["attempts"] -= 1 + if remaining["attempts"] > 0: + QTimer.singleShot(_DIALOG_RETRY_INTERVAL_MS, shut) QTimer.singleShot(ms, shut) diff --git a/test/unit_test/headless/test_usbip.py b/test/unit_test/headless/test_usbip.py index 897ec129..b46cd771 100644 --- a/test/unit_test/headless/test_usbip.py +++ b/test/unit_test/headless/test_usbip.py @@ -241,6 +241,34 @@ def test_server_forwards_urb_to_backend(): server.stop() +# --- lifecycle race -------------------------------------------------- + +def test_rapid_start_stop_never_leaks_a_thread_exception(monkeypatch): + """A concurrent stop() must not blow up the accept thread. + + Regression: start() published the listening socket and left the accept + thread to call settimeout() on it, outside any try. A stop() closing the + socket in that window made settimeout raise WSAENOTSOCK (WinError 10038) + with nothing to catch it — the accept thread died with an unhandled + traceback on ~18% of tight start/stop cycles. The fix sets the timeout on + the owning thread in start(), before publishing the socket. + """ + caught: list = [] + original = threading.excepthook + threading.excepthook = lambda args: caught.append( + (args.thread.name, type(args.exc_value).__name__)) + try: + for _ in range(80): + server = UsbIpServer(FakeUrbBackend(devices=[_device()]), + host="127.0.0.1", port=0) + server.start() + server.stop(timeout=0.2) + finally: + threading.excepthook = original + + assert caught == [], f"accept thread raised: {caught[:3]}" + + # --- helpers --------------------------------------------------------- def _recv(sock: socket.socket, n: int) -> bytes: diff --git a/test/unit_test/headless/test_wayland_backend.py b/test/unit_test/headless/test_wayland_backend.py index 0bf6e329..c6a5ad93 100644 --- a/test/unit_test/headless/test_wayland_backend.py +++ b/test/unit_test/headless/test_wayland_backend.py @@ -252,6 +252,69 @@ def test_screenshot_passes_screen_region(): ]] +@pytest.mark.parametrize("direction_name, expected_axis, expected_amount", [ + ("wayland_scroll_direction_down", "-y", "-5"), + ("wayland_scroll_direction_up", "-y", "5"), + ("wayland_scroll_direction_left", "-x", "-5"), + ("wayland_scroll_direction_right", "-x", "5"), +]) +def test_scroll_honours_direction(direction_name, expected_axis, + expected_amount): + """Regression: scroll's signature was ``(direction, x, y)`` while the + wrapper calls ``scroll(scroll_value, scroll_direction)``. The direction + bound to ``x`` and was then dropped, so every direction scrolled the same + way — up and down emitted byte-identical argv. + """ + captured: list = [] + direction = getattr(wayland_mouse, direction_name) + with patch.object(wayland_mouse, "binary_path", + return_value="/usr/bin/ydotool"), \ + patch.object(wayland_mouse.subprocess, "run", + side_effect=_fake_run(captured)): + wayland_mouse.scroll(5, direction) + assert captured[0][1:] == [ + "mousemove", "--wheel", expected_axis, expected_amount, + ] + + +def test_scroll_opposite_directions_are_not_identical(): + """The sharpest form of the regression: up and down must differ.""" + captured: list = [] + with patch.object(wayland_mouse, "binary_path", + return_value="/usr/bin/ydotool"), \ + patch.object(wayland_mouse.subprocess, "run", + side_effect=_fake_run(captured)): + wayland_mouse.scroll(5, wayland_mouse.wayland_scroll_direction_down) + wayland_mouse.scroll(5, wayland_mouse.wayland_scroll_direction_up) + assert captured[0] != captured[1] + + +def test_get_pixel_grabs_a_one_by_one_region_at_the_point(): + """The wrapper calls ``screen.get_pixel``; Wayland had no such function + at all, so every get_pixel raised AttributeError on Wayland.""" + png = _one_pixel_png((10, 20, 30)) + captured: list = [] + + def _run(argv, **_kwargs): + captured.append(argv) + return subprocess.CompletedProcess(argv, 0, png, b"") # nosemgrep + + with patch.object(wayland_screen, "binary_path", + return_value="/usr/bin/grim"), \ + patch.object(wayland_screen.subprocess, "run", side_effect=_run): + assert wayland_screen.get_pixel(7, 9) == (10, 20, 30) + assert "-g" in captured[0] and "7,9 1x1" in captured[0] + + +def _one_pixel_png(rgb) -> bytes: + from io import BytesIO + + from PIL import Image + buffer = BytesIO() + Image.new("RGB", (1, 1), rgb).save(buffer, format="PNG") + return buffer.getvalue() + + def test_screen_size_uses_wlr_randr_when_available(): with patch.object(wayland_screen, "binary_path", side_effect=lambda name: "/usr/bin/" + name), \ @@ -261,7 +324,7 @@ def test_screen_size_uses_wlr_randr_when_available(): ["wlr-randr"], 0, b" HDMI-A-1 1920x1080@60.000Hz\n", b"", ), ): - assert wayland_screen.screen_size() == (1920, 1080) + assert wayland_screen.size() == (1920, 1080) def test_screenshot_raises_when_grim_missing():