Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ reference page:
**Smart waits**
- **Wait for a file** — `wait_until_file` (`AC_wait_for_file`) blocks until a file exists and its size stops growing (a download finished writing).
- **Wait for a TCP port** — `wait_until_port` (`AC_wait_for_port`) blocks until `host:port` accepts connections (pairs with `launch_process`).
- **Wait for a process** — `wait_until_process` (`AC_wait_for_process`) blocks until a process appears or exits — the companion to `launch_process` / `kill_process` (requires psutil).

**Security** — HTTP / SMTP enforce http/https or TLS with verified certificates and explicit timeouts; SQL is read-only and parameter-bound; file paths are resolved before I/O.

Expand Down
1 change: 1 addition & 0 deletions README/README_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
**智能等待**
- **等待文件** — `wait_until_file`(`AC_wait_for_file`):等到文件存在且大小停止增长(下载写完)。
- **等待 TCP 端口** — `wait_until_port`(`AC_wait_for_port`):等到 `host:port` 可连接(与 `launch_process` 互补)。
- **等待进程** — `wait_until_process`(`AC_wait_for_process`):等到进程出现或退出(与 `launch_process` / `kill_process` 互补;需 psutil)。

**安全性** — HTTP/SMTP 强制 http/https 或已验证 TLS 与明确超时;SQL 只读且参数绑定;文件路径 I/O 前以 `realpath` 解析。

Expand Down
1 change: 1 addition & 0 deletions README/README_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
**智慧等待**
- **等待檔案** — `wait_until_file`(`AC_wait_for_file`):等到檔案存在且大小停止增長(下載寫完)。
- **等待 TCP 連接埠** — `wait_until_port`(`AC_wait_for_port`):等到 `host:port` 可連線(與 `launch_process` 互補)。
- **等待行程** — `wait_until_process`(`AC_wait_for_process`):等到行程出現或結束(與 `launch_process` / `kill_process` 互補;需 psutil)。

**安全性** — HTTP/SMTP 強制 http/https 或已驗證 TLS 與明確逾時;SQL 唯讀且參數綁定;檔案路徑 I/O 前以 `realpath` 解析。

Expand Down
16 changes: 11 additions & 5 deletions docs/source/Eng/doc/new_features/v5_features_doc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,24 @@ Smart waits

Two waits that replace unreliable ``sleep`` calls::

from je_auto_control import wait_until_file, wait_until_port
from je_auto_control import (
wait_until_file, wait_until_port, wait_until_process)

wait_until_file("~/Downloads/report.pdf", stable_for_s=1.0)
wait_until_port("127.0.0.1", 8080, timeout_s=30.0)
wait_until_process("myserver", present=True, timeout_s=30.0)

``wait_until_file`` returns once a file exists, is at least ``min_size``
bytes, and its size has held steady for ``stable_for_s`` (a download has
finished). ``wait_until_port`` returns once a TCP connection to
``host:port`` succeeds — the companion to launching a server. Both return
a ``WaitOutcome`` and honour a hard ``timeout_s`` cap.

Executor commands: ``AC_wait_for_file``, ``AC_wait_for_port``.
``host:port`` succeeds — the companion to launching a server. ``wait_until_process``
returns once a process whose name contains the target appears (or, with
``present=False``, exits) — the companion to ``launch_process`` /
``kill_process`` (requires psutil). All return a ``WaitOutcome`` and honour
a hard ``timeout_s`` cap.

Executor commands: ``AC_wait_for_file``, ``AC_wait_for_port``,
``AC_wait_for_process``.


Security
Expand Down
10 changes: 8 additions & 2 deletions docs/source/Zh/doc/new_features/v5_features_doc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,23 @@ PDF

兩個用來取代不可靠 ``sleep`` 的等待::

from je_auto_control import wait_until_file, wait_until_port
from je_auto_control import (
wait_until_file, wait_until_port, wait_until_process)

wait_until_file("~/Downloads/report.pdf", stable_for_s=1.0)
wait_until_port("127.0.0.1", 8080, timeout_s=30.0)
wait_until_process("myserver", present=True, timeout_s=30.0)

``wait_until_file`` 會在檔案存在、達到 ``min_size`` 位元組、且大小持續
``stable_for_s`` 秒不變(下載寫完)後回傳。``wait_until_port`` 會在
``host:port`` 可接受 TCP 連線後回傳——是啟動伺服器的最佳搭檔。兩者都
回傳 ``WaitOutcome`` 並有硬性 ``timeout_s`` 上限。

執行器指令:``AC_wait_for_file``、``AC_wait_for_port``。
``wait_until_process`` 會在名稱含目標字串的行程出現(或 ``present=False``
時結束)後回傳——是 ``launch_process`` / ``kill_process`` 的搭檔(需
psutil)。

執行器指令:``AC_wait_for_file``、``AC_wait_for_port``、``AC_wait_for_process``。


安全性
Expand Down
6 changes: 3 additions & 3 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@
# Smart waits (frame-diff replacements for time.sleep)
from je_auto_control.utils.smart_waits import (
WaitOutcome, wait_until_clipboard_changes, wait_until_file,
wait_until_pixel_changes, wait_until_port, wait_until_region_idle,
wait_until_screen_stable, wait_until_window_closed,
wait_until_pixel_changes, wait_until_port, wait_until_process,
wait_until_region_idle, wait_until_screen_stable, wait_until_window_closed,
)
# Assertion DSL (verify screen state; raise on mismatch)
from je_auto_control.utils.assertion import (
Expand Down Expand Up @@ -569,7 +569,7 @@ def start_autocontrol_gui(*args, **kwargs):
"WaitOutcome", "wait_until_pixel_changes",
"wait_until_region_idle", "wait_until_screen_stable",
"wait_until_clipboard_changes", "wait_until_window_closed",
"wait_until_file", "wait_until_port",
"wait_until_file", "wait_until_port", "wait_until_process",
# Assertion DSL
"AssertionResult", "assert_image", "assert_pixel",
"assert_text", "assert_window", "assert_clipboard", "assert_process",
Expand Down
12 changes: 12 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,18 @@ def _add_window_specs(specs: List[CommandSpec]) -> None:
),
description="Wait until a TCP host:port accepts connections.",
))
specs.append(CommandSpec(
"AC_wait_for_process", "Flow", "Wait for Process",
fields=(
FieldSpec("name", FieldType.STRING),
FieldSpec("present", FieldType.BOOL, optional=True, default=True),
FieldSpec("timeout_s", FieldType.FLOAT, optional=True,
default=30.0),
FieldSpec("poll_interval_s", FieldType.FLOAT, optional=True,
default=0.25, min_value=0.01),
),
description="Wait until a process appears (or exits). Requires psutil.",
))
specs.append(CommandSpec("AC_list_windows", "Window", "List Windows"))
specs.append(CommandSpec(
"AC_capture_window", "Window", "Capture Window",
Expand Down
11 changes: 11 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,16 @@ def _wait_for_port(host: str, port: int, timeout_s: float = 30.0,
).to_dict()


def _wait_for_process(name: str, present: bool = True, timeout_s: float = 30.0,
poll_interval_s: float = 0.25) -> Dict[str, Any]:
"""Executor adapter: wait until a process appears or exits."""
from je_auto_control.utils.smart_waits import wait_until_process
return wait_until_process(
name, present=bool(present), timeout_s=float(timeout_s),
poll_interval_s=float(poll_interval_s),
).to_dict()


def _ocr_read_structure(region: Optional[List[int]] = None,
lang: str = "eng",
min_confidence: float = 60.0,
Expand Down Expand Up @@ -2393,6 +2403,7 @@ def __init__(self):
"AC_wait_region_idle": _wait_region_idle,
"AC_wait_for_file": _wait_for_file,
"AC_wait_for_port": _wait_for_port,
"AC_wait_for_process": _wait_for_process,
"AC_wait_clipboard_change": _wait_clipboard_change,
"AC_wait_window_closed": _wait_window_closed,

Expand Down
16 changes: 16 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,22 @@ def smart_wait_tools() -> List[MCPTool]:
handler=h.wait_for_port,
annotations=READ_ONLY,
),
MCPTool(
name="ac_wait_for_process",
description=("Block until a process whose name contains 'name' "
"appears (present=true) or exits (present=false) — "
"e.g. after launching or killing one. Requires "
"psutil. Returns a WaitOutcome (succeeded/reason/"
"elapsed_s)."),
input_schema=schema({
"name": {"type": "string"},
"present": {"type": "boolean"},
"timeout_s": {"type": "number"},
"poll_interval_s": {"type": "number"},
}, required=["name"]),
handler=h.wait_for_process,
annotations=READ_ONLY,
),
]


Expand Down
9 changes: 9 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,15 @@ def wait_for_port(host: str, port: int, timeout_s: float = 30.0,
).to_dict()


def wait_for_process(name: str, present: bool = True, timeout_s: float = 30.0,
poll_interval_s: float = 0.25) -> Dict[str, Any]:
from je_auto_control.utils.smart_waits import wait_until_process
return wait_until_process(
name, present=bool(present), timeout_s=float(timeout_s),
poll_interval_s=float(poll_interval_s),
).to_dict()


def wait_pixel_changes(x: int, y: int,
timeout_s: float = 10.0,
poll_interval_s: float = 0.1,
Expand Down
16 changes: 9 additions & 7 deletions je_auto_control/utils/smart_waits/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@
)
"""
from je_auto_control.utils.smart_waits.waits import (
ClipboardReader, FileStatReader, Frame, PortConnector, ScreenSampler,
WaitOutcome, WindowFinder, wait_until_clipboard_changes, wait_until_file,
wait_until_pixel_changes, wait_until_port, wait_until_region_idle,
wait_until_screen_stable, wait_until_window_closed,
ClipboardReader, FileStatReader, Frame, PortConnector, ProcessLister,
ScreenSampler, WaitOutcome, WindowFinder, wait_until_clipboard_changes,
wait_until_file, wait_until_pixel_changes, wait_until_port,
wait_until_process, wait_until_region_idle, wait_until_screen_stable,
wait_until_window_closed,
)


__all__ = [
"ClipboardReader", "FileStatReader", "Frame", "PortConnector",
"ScreenSampler", "WaitOutcome", "WindowFinder",
"ProcessLister", "ScreenSampler", "WaitOutcome", "WindowFinder",
"wait_until_clipboard_changes", "wait_until_file",
"wait_until_pixel_changes", "wait_until_port", "wait_until_region_idle",
"wait_until_screen_stable", "wait_until_window_closed",
"wait_until_pixel_changes", "wait_until_port", "wait_until_process",
"wait_until_region_idle", "wait_until_screen_stable",
"wait_until_window_closed",
]
46 changes: 43 additions & 3 deletions je_auto_control/utils/smart_waits/waits.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,45 @@ def _default_port_connector(host: str, port: int, timeout: float) -> bool:
return False


ProcessLister = Callable[[str], List[str]]


def wait_until_process(name: str, *, present: bool = True,
timeout_s: float = 30.0,
poll_interval_s: float = 0.25,
lister: Optional[ProcessLister] = None,
) -> WaitOutcome:
"""Return when a process whose name contains ``name`` appears, or exits.

The companion to launching / killing a process: poll until a matching
process exists (``present=True``) or is gone (``present=False``).
``lister(name) -> [matching names]`` is injectable so tests need no
real processes; the default uses psutil.
"""
if timeout_s <= 0:
raise ValueError(_TIMEOUT_POSITIVE)
if poll_interval_s <= 0:
raise ValueError(_POLL_POSITIVE)
find = lister or _default_process_lister
started = time.monotonic()
deadline = started + float(timeout_s)
samples = 0
verb = "appeared" if present else "exited"
while time.monotonic() < deadline:
samples += 1
if bool(find(name)) == bool(present):
return _finish(True, f"process {name!r} {verb}", started, samples)
time.sleep(float(poll_interval_s))
return _finish(False, f"timeout waiting for process {name!r} to {verb}",
started, samples)


def _default_process_lister(name: str) -> List[str]:
"""List running process names matching ``name`` (requires psutil)."""
from je_auto_control.utils.assertion.assertions import _running_process_names
return _running_process_names(name)


# --- internals -------------------------------------------------

def _frame_diff(a: Frame, b: Frame) -> int:
Expand Down Expand Up @@ -382,8 +421,9 @@ def _finish(succeeded: bool, reason: str, started: float,

__all__ = [
"ClipboardReader", "FileStatReader", "Frame", "PortConnector",
"ScreenSampler", "WaitOutcome", "WindowFinder",
"ProcessLister", "ScreenSampler", "WaitOutcome", "WindowFinder",
"wait_until_clipboard_changes", "wait_until_file",
"wait_until_pixel_changes", "wait_until_port", "wait_until_region_idle",
"wait_until_screen_stable", "wait_until_window_closed",
"wait_until_pixel_changes", "wait_until_port", "wait_until_process",
"wait_until_region_idle", "wait_until_screen_stable",
"wait_until_window_closed",
]
66 changes: 66 additions & 0 deletions test/unit_test/headless/test_wait_for_process.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Headless tests for AC_wait_for_process / wait_until_process.

The process lister is injectable, so the tests need neither psutil nor
real processes.
"""
import pytest

import je_auto_control as ac
from je_auto_control.utils.smart_waits.waits import wait_until_process


def _lister(values):
"""Return a lister yielding ``values`` then repeating the last one."""
seq = list(values)

def find(_name):
return seq.pop(0) if len(seq) > 1 else seq[0]
return find


def test_succeeds_when_process_present():
outcome = wait_until_process("chrome", lister=_lister([["chrome.exe"]]))
assert outcome.succeeded is True
assert "appeared" in outcome.reason


def test_succeeds_after_process_appears():
outcome = wait_until_process(
"svc", timeout_s=1.0, poll_interval_s=0.01,
lister=_lister([[], [], ["svc"]]))
assert outcome.succeeded is True
assert outcome.samples_taken >= 3


def test_succeeds_when_process_absent():
outcome = wait_until_process("gone", present=False, lister=_lister([[]]))
assert outcome.succeeded is True
assert "exited" in outcome.reason


def test_times_out_when_never_present():
outcome = wait_until_process(
"missing", timeout_s=0.2, poll_interval_s=0.02, lister=_lister([[]]))
assert outcome.succeeded is False
assert "timeout" in outcome.reason


@pytest.mark.parametrize("kwargs", [{"timeout_s": 0}, {"poll_interval_s": 0}])
def test_validation_errors(kwargs):
with pytest.raises(ValueError):
wait_until_process("x", lister=_lister([["x"]]), **kwargs)


def test_facade_executor_and_mcp_wiring(monkeypatch):
import je_auto_control.utils.smart_waits.waits as waits_module
monkeypatch.setattr(waits_module, "_default_process_lister",
lambda _name: []) # no real psutil / processes
assert ac.wait_until_process is wait_until_process
assert "AC_wait_for_process" in ac.executor.known_commands()
record = ac.execute_action(
[["AC_wait_for_process", {"name": "anything", "present": False,
"timeout_s": 0.5, "poll_interval_s": 0.02}]])
assert any("exited" in str(v) for v in record.values())
from je_auto_control.utils.mcp_server.tools import build_default_tool_registry
names = {tool.name for tool in build_default_tool_registry()}
assert "ac_wait_for_process" in names
Loading