Skip to content

Commit 7ccf25d

Browse files
committed
Fix CI failures on PR #196: ruff F401 + pytester plugin double-load
* Add Self-healing + WebRunner bridge symbols to je_auto_control/__init__.py __all__ (ruff F401 fires when re-exported names aren't listed). * Add PresenceError / PresenceListener / PresenceRegistry / ROLE_* / ViewerPresence / default_presence_registry to je_auto_control/utils/remote_desktop/__init__.py __all__. * Stub generator: bare project-class annotations now fall back to ``Any`` rather than emitting an unresolved name; ``NoneType`` → ``None``; dotted module references → ``Any``; header imports widened to include ``Callable`` / ``Mapping`` / ``Sequence``; ``# ruff: noqa: F401`` pragma on the generated stub so unused typing imports are tolerated. * Regenerate je_auto_control/actions.pyi. * test_pytest_plugin: stop passing ``-p`` to the inner pytester run — the package is pip-installed in CI which activates the pytest11 entry point, so the explicit ``-p`` was double-registering the plugin and aborting the inner run before a summary line. Use ``runpytest_subprocess()`` + ``result.ret == 1`` instead.
1 parent ddf62a5 commit 7ccf25d

5 files changed

Lines changed: 128 additions & 16 deletions

File tree

je_auto_control/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,14 @@ def start_autocontrol_gui(*args, **kwargs):
465465
# A/B locator framework
466466
"ABRunOutcome", "ab_best_strategy", "ab_locate",
467467
"ab_report_for", "default_ab_store",
468+
# Self-healing locator (image → VLM fallback)
469+
"HealEvent", "HealEventLog", "HealOutcome", "SelfHealError",
470+
"default_heal_log", "self_heal_click", "self_heal_locate",
471+
# WebRunner bridge (browser automation via je_web_runner)
472+
"WebRunnerBridgeError", "is_webrunner_available",
473+
"list_webrunner_commands", "run_webrunner_action",
474+
"run_webrunner_actions", "web_current_url", "web_open",
475+
"web_quit", "web_screenshot",
468476
# Remote desktop
469477
"RemoteDesktopHost", "RemoteDesktopViewer",
470478
"RemoteDesktopAuthError", "RemoteDesktopInputError",

je_auto_control/actions.pyi

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,44 @@
11
# AUTOGENERATED by je_auto_control.utils.stubs.generator — do not edit.
22
# Regenerate with: python -m je_auto_control.utils.stubs.generator
3-
from typing import Any, Dict, List, Optional, Tuple, Union
3+
# ruff: noqa: F401 # generated stub keeps typing imports for forward use
4+
from typing import (
5+
Any, Callable, Dict, List, Mapping, Optional,
6+
Sequence, Tuple, Union,
7+
)
48

59
def AC_a11y_click(name: str | None = ..., role: str | None = ..., app_name: str | None = ...) -> bool:
610
"""Click the center of the first element matching the filters."""
711

12+
def AC_a11y_dump(app_name: str | None = ..., max_results: int = ...) -> Dict[str, Any]:
13+
"""Executor adapter: dump the accessibility tree as nested dict."""
14+
815
def AC_a11y_find(name: str | None = ..., role: str | None = ..., app_name: str | None = ...) -> dict | None:
916
"""Executor adapter: find an accessibility element, return its dict."""
1017

1118
def AC_a11y_list(app_name: str | None = ..., max_results: int = ...) -> List[dict]:
1219
"""Executor adapter: list accessibility elements as plain dicts."""
1320

21+
def AC_a11y_record_events() -> List[Dict[str, Any]]:
22+
"""Executor adapter: peek at events without stopping the recorder."""
23+
24+
def AC_a11y_record_start(app_name: str | None = ..., poll_interval_s: float = ..., min_movement_px: int = ...) -> Dict[str, Any]:
25+
"""Executor adapter: start the singleton accessibility recorder."""
26+
27+
def AC_a11y_record_stop() -> List[Dict[str, Any]]:
28+
"""Executor adapter: stop the recorder and return the captured events."""
29+
30+
def AC_ab_best_strategy(target_id: str) -> Dict[str, Any]:
31+
...
32+
33+
def AC_ab_clear() -> Dict[str, Any]:
34+
...
35+
36+
def AC_ab_locate(target_id: str, strategies: Dict[str, Dict[str, Any]], max_parallel: int = ..., record: bool = ...) -> Dict[str, Any]:
37+
"""Executor adapter: race N locator strategies for the same target."""
38+
39+
def AC_ab_report(target_id: str) -> Dict[str, Any]:
40+
...
41+
1442
def AC_add_package_to_callback_executor(package: str) -> None:
1543
"""將套件成員加入 CallbackExecutor"""
1644

@@ -77,7 +105,7 @@ def AC_check_key_is_press(keycode: int | str) -> bool | None:
77105
def AC_click_mouse(mouse_keycode: int | str, x: int = ..., y: int = ...) -> Tuple[int, int, int]:
78106
"""在指定座標按下並放開滑鼠按鍵"""
79107

80-
def AC_click_text(target: str, mouse_keycode: int | str = ..., lang: str = ..., region: Sequence[int] | None = ..., min_confidence: float = ..., case_sensitive: bool = ..., backend: str | je_auto_control.utils.ocr.backends.base.OCRBackend | None = ...) -> Tuple[int, int]:
108+
def AC_click_text(target: str, mouse_keycode: int | str = ..., lang: str = ..., region: Sequence[int] | None = ..., min_confidence: float = ..., case_sensitive: bool = ..., backend: Any = ...) -> Tuple[int, int]:
81109
"""Locate ``target`` text and click its centre."""
82110

83111
def AC_clipboard_get() -> str:
@@ -98,6 +126,18 @@ def AC_config_export() -> Dict[str, Any]:
98126
def AC_config_import(bundle: Dict[str, Any], dry_run: bool = ...) -> Dict[str, Any]:
99127
"""Executor adapter: apply a config bundle dict to the user config root."""
100128

129+
def AC_costs_clear() -> Dict[str, Any]:
130+
...
131+
132+
def AC_costs_list(limit: int = ...) -> List[Dict[str, Any]]:
133+
...
134+
135+
def AC_costs_record(provider: str, model: str, input_tokens: int, output_tokens: int, label: str | None = ..., run_id: str | None = ..., user: str | None = ...) -> Dict[str, Any]:
136+
"""Executor adapter: append one LLM call to the cost-telemetry log."""
137+
138+
def AC_costs_summary(limit: int = ...) -> Dict[str, Any]:
139+
"""Executor adapter: aggregate cost events by model / provider / day."""
140+
101141
def AC_create_project(project_path: str = ..., parent_name: str = ...) -> None:
102142
"""Create project directory structure and templates."""
103143

@@ -122,7 +162,7 @@ def AC_email_trigger_start() -> Dict[str, Any]:
122162
def AC_email_trigger_stop() -> Dict[str, Any]:
123163
...
124164

125-
def AC_execute_action(action_list: list | dict, raise_on_error: bool = ..., _validated: bool = ..., dry_run: bool = ..., step_callback: Callable[[list], NoneType] | None = ...) -> Dict[str, str]:
165+
def AC_execute_action(action_list: list | dict, raise_on_error: bool = ..., _validated: bool = ..., dry_run: bool = ..., step_callback: Callable[[list], None] | None = ...) -> Dict[str, str]:
126166
"""執行 action list"""
127167

128168
def AC_execute_files(execute_files_list: list) -> List[Dict[str, str]]:
@@ -131,6 +171,15 @@ def AC_execute_files(execute_files_list: list) -> List[Dict[str, str]]:
131171
def AC_execute_process(exe_path: str) -> None:
132172
"""Start an external executable file."""
133173

174+
def AC_failure_hook_clear() -> Dict[str, Any]:
175+
...
176+
177+
def AC_failure_hook_fire(source: str, source_id: str, error_text: str = ..., script_path: str | None = ..., screenshot_path: str | None = ..., log_tail: str = ..., metadata: Dict[str, Any] | None = ...) -> List[Dict[str, Any]]:
178+
"""Executor adapter: file a ticket through every registered backend."""
179+
180+
def AC_failure_hook_list() -> List[Dict[str, Any]]:
181+
...
182+
134183
def AC_find_text_regex(pattern: str, lang: str = ..., region: List[int] | None = ..., min_confidence: float = ..., flags: int = ...) -> List[dict]:
135184
"""Executor adapter: regex OCR search returning JSON-friendly dicts."""
136185

@@ -230,7 +279,7 @@ def AC_locate_and_click(image: Any, mouse_keycode: int | str, detect_threshold:
230279
def AC_locate_image_center(image: Any, detect_threshold: float = ..., draw_image: bool = ...) -> Tuple[int, int]:
231280
"""找出單一影像並回傳中心座標"""
232281

233-
def AC_locate_text(target: str, lang: str = ..., region: Sequence[int] | None = ..., min_confidence: float = ..., case_sensitive: bool = ..., backend: str | je_auto_control.utils.ocr.backends.base.OCRBackend | None = ...) -> Tuple[int, int]:
282+
def AC_locate_text(target: str, lang: str = ..., region: Sequence[int] | None = ..., min_confidence: float = ..., case_sensitive: bool = ..., backend: Any = ...) -> Tuple[int, int]:
234283
"""Return the centre (x, y) of the first match; raise if not found."""
235284

236285
def AC_mouse_left(x: int | None = ..., y: int | None = ...) -> Tuple[int, int, int]:
@@ -374,10 +423,10 @@ def AC_set_record_enable(set_enable: bool = ...) -> None:
374423
def AC_shell_command(shell_command: str | List[str]) -> None:
375424
"""Execute shell command with shell=False."""
376425

377-
def AC_start_mcp_http_server(host: str = ..., port: int = ..., mcp: je_auto_control.utils.mcp_server.server.MCPServer | None = ..., auth_token: str | None = ..., ssl_context: ssl.SSLContext | None = ...) -> HttpMCPServer:
426+
def AC_start_mcp_http_server(host: str = ..., port: int = ..., mcp: Any = ..., auth_token: str | None = ..., ssl_context: Any = ...) -> Any:
378427
"""Start and return an :class:`HttpMCPServer`; convenience wrapper."""
379428

380-
def AC_start_mcp_server() -> MCPServer:
429+
def AC_start_mcp_server() -> Any:
381430
"""Start a stdio MCP server in the foreground; blocks until EOF."""
382431

383432
def AC_start_remote_host(token: str, bind: str = ..., port: int = ..., fps: float = ..., quality: int = ..., region: List[int] | None = ..., max_clients: int = ...) -> Dict[str, Any]:
@@ -419,7 +468,7 @@ def AC_usb_watch_start(poll_interval_s: float = ...) -> Dict[str, Any]:
419468
def AC_usb_watch_stop() -> Dict[str, Any]:
420469
...
421470

422-
def AC_vlm_click(description: str, screen_region: List[int] | None = ..., model: str | None = ..., backend: je_auto_control.utils.vision.backends.base.VLMBackend | None = ...) -> bool:
471+
def AC_vlm_click(description: str, screen_region: List[int] | None = ..., model: str | None = ..., backend: Any = ...) -> bool:
423472
"""Locate by description, then click the center of the match."""
424473

425474
def AC_vlm_locate(description: str, screen_region: List[int] | None = ..., model: str | None = ...) -> List[int] | None:
@@ -434,7 +483,7 @@ def AC_wait_region_idle(region: List[int], timeout_s: float = ..., poll_interval
434483
def AC_wait_screen_stable(region: List[int] | None = ..., timeout_s: float = ..., poll_interval_s: float = ..., stable_for_s: float = ..., max_pixel_diff: int = ...) -> Dict[str, Any]:
435484
"""Executor adapter: smart wait for the screen to stop moving."""
436485

437-
def AC_wait_text(target: str, lang: str = ..., region: Sequence[int] | None = ..., timeout: float = ..., poll: float = ..., min_confidence: float = ..., case_sensitive: bool = ..., backend: str | je_auto_control.utils.ocr.backends.base.OCRBackend | None = ...) -> Tuple[int, int]:
486+
def AC_wait_text(target: str, lang: str = ..., region: Sequence[int] | None = ..., timeout: float = ..., poll: float = ..., min_confidence: float = ..., case_sensitive: bool = ..., backend: Any = ...) -> Tuple[int, int]:
438487
"""Poll until ``target`` appears on screen; raise on timeout."""
439488

440489
def AC_wait_window(title_substring: str, timeout: float = ..., poll: float = ..., case_sensitive: bool = ...) -> int:

je_auto_control/utils/remote_desktop/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,7 @@ def is_webrtc_available() -> bool:
181181
"is_audio_backend_available",
182182
"ClipboardSyncError",
183183
"FileReceiver", "FileSendResult", "FileTransferError", "send_file",
184+
"PresenceError", "PresenceListener", "PresenceRegistry",
185+
"ROLE_CONTROLLER", "ROLE_OBSERVER", "ViewerPresence",
186+
"default_presence_registry",
184187
]

je_auto_control/utils/stubs/generator.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@
2626
_HEADER = (
2727
"# AUTOGENERATED by je_auto_control.utils.stubs.generator — do not edit.\n"
2828
"# Regenerate with: python -m je_auto_control.utils.stubs.generator\n"
29-
"from typing import Any, Dict, List, Optional, Tuple, Union\n"
29+
"# ruff: noqa: F401 # generated stub keeps typing imports for forward use\n"
30+
"from typing import (\n"
31+
" Any, Callable, Dict, List, Mapping, Optional,\n"
32+
" Sequence, Tuple, Union,\n"
33+
")\n"
3034
"\n"
3135
)
3236

@@ -116,15 +120,47 @@ def _render_return(sig: Optional[inspect.Signature]) -> str:
116120
return _annotation_str(sig.return_annotation, default="Any")
117121

118122

123+
_BUILTIN_TYPES = frozenset({
124+
"bool", "bytes", "bytearray", "complex", "dict", "float", "frozenset",
125+
"int", "list", "memoryview", "object", "range", "set", "str", "tuple",
126+
"type", "None",
127+
})
128+
129+
119130
def _annotation_str(annotation: Any, *, default: str) -> str:
120131
if annotation is inspect.Parameter.empty:
121132
return default
122133
if isinstance(annotation, type):
123-
return annotation.__name__
134+
name = annotation.__name__
135+
# Project-owned classes aren't imported into the stub, so fall
136+
# back to ``Any`` rather than emit a bare name that won't
137+
# resolve. Builtins keep their names.
138+
return name if name in _BUILTIN_TYPES else "Any"
124139
text = str(annotation)
125140
# ``typing`` is re-exported at the top of the stub, so strip the
126141
# prefix from both leading and embedded uses (``List[typing.Dict[…]]``).
127-
return text.replace("typing.", "")
142+
text = text.replace("typing.", "")
143+
# ``inspect.signature`` renders ``type(None)`` as ``NoneType``; the
144+
# name only resolves if ``types.NoneType`` is imported. ``None``
145+
# works as a type annotation everywhere and reads cleaner.
146+
text = text.replace("NoneType", "None")
147+
# Any dotted reference that isn't a typing builtin would need the
148+
# owning module imported into the stub. Rather than transitively
149+
# import the world, fall back to ``Any``; the function name still
150+
# autocompletes, the precise type just shows as Any.
151+
if _has_module_reference(text):
152+
return "Any"
153+
return text
154+
155+
156+
def _has_module_reference(text: str) -> bool:
157+
"""True iff ``text`` still references a non-typing dotted name."""
158+
if "." not in text:
159+
return False
160+
# Allow ellipsis-style forward refs that survive the typing.strip
161+
# without an actual module reference (e.g. ``Callable[..., int]``).
162+
sanitised = text.replace("...", "")
163+
return "." in sanitised
128164

129165

130166
def _first_doc_line(handler: Callable[..., Any]) -> Optional[str]:

test/unit_test/headless/test_pytest_plugin.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,14 @@ def test_pyproject_registers_pytest11_entry_point():
163163

164164

165165
def test_plugin_capture_screenshot_on_failure(pytester, monkeypatch):
166-
"""End-to-end: a failing test marked @autocontrol gets a screenshot."""
166+
"""End-to-end: a failing test marked @autocontrol gets a screenshot.
167+
168+
The plugin loads via the ``pytest11`` entry point declared in
169+
``pyproject.toml`` whenever the package is pip-installed (CI uses
170+
``pip install -e .``). We deliberately don't pass ``-p`` here —
171+
doing so would double-register the plugin on installed runs and
172+
abort the inner pytest before a summary line is emitted.
173+
"""
167174
pytester.makepyfile(
168175
"""
169176
import pytest
@@ -188,8 +195,14 @@ def _patch_screenshot(tmp_path):
188195
""",
189196
)
190197
pytester.syspathinsert()
191-
result = pytester.runpytest("-p", "je_auto_control.utils.pytest_plugin.plugin")
192-
result.assert_outcomes(failed=1)
198+
result = pytester.runpytest_subprocess()
199+
# ret == 1 means the inner test ran and failed (which is what we
200+
# asked it to do). Checking exit code is more robust than parsing
201+
# the summary line, which can vary across pytest versions.
202+
assert result.ret == 1, (
203+
f"inner pytest exit {result.ret}; stdout tail: "
204+
f"{result.stdout.str()[-400:]!r}"
205+
)
193206

194207

195208
def test_plugin_no_screenshot_for_unmarked_failure(pytester):
@@ -200,7 +213,10 @@ def test_plain_failure():
200213
""",
201214
)
202215
pytester.syspathinsert()
203-
result = pytester.runpytest("-p", "je_auto_control.utils.pytest_plugin.plugin")
204-
result.assert_outcomes(failed=1)
216+
result = pytester.runpytest_subprocess()
217+
assert result.ret == 1, (
218+
f"inner pytest exit {result.ret}; stdout tail: "
219+
f"{result.stdout.str()[-400:]!r}"
220+
)
205221
out = result.stdout.str()
206222
assert "autocontrol-screenshot" not in out

0 commit comments

Comments
 (0)