feat(rpa): add poll_until, env error codes, optional launch timeout (1.2.11)
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 19s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 19s
Shared readiness wait and thin ERROR:UI_NOT_READY / BROWSER_START_TIMEOUT / ENV_DEGRADED codes so skills can separate slow machines from config failures. Optional browser launch timeout and read-only power/throttle diagnostics (non-blocking). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
26
README.md
26
README.md
@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
|
||||
|
||||
## 3. 包含的模块
|
||||
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_skill_core**:权益 HTTP 客户端、`enforce_entitlement`、统一文件日志、`JIANGCHANG_*` 数据根与技能根路径解析、**Skill Activity Stream / Job 运行时**(`emit` / `finish` / Run Journal)、兄弟技能 CLI 桥(`sibling_bridge`)、共享媒体资源(`media_assets`)、**RPA 有界就绪等待**(`rpa.poll_until`)、可选浏览器启动超时与只读机器供电/限频探测(`machine_power`,不阻断 health)等,供 Skill CLI / 服务进程使用。
|
||||
- **jiangchang_desktop_sdk**:匠厂桌面应用自动化客户端(基于 CDP + Playwright 同步 API)。**本包不声明 Playwright 依赖**;请在运行环境中自行安装 Playwright,以便 `import playwright` 可用。
|
||||
- **jiangchang_desktop_wake**:Windows 桌面 UIA 暖树。技能/拾取请用 `ensure_ui_ready("wecom")`(内部 `n2-wake`:注入 + DuiLib GetAccessible)。随包携带 x86 `native/desktop-wake.exe` 与 `wecom_wake.dll`。详见 `src/jiangchang_desktop_wake/README.md`。探测深遍历需另装 `uiautomation`。
|
||||
|
||||
@@ -278,6 +278,30 @@ print(runtime_diagnostics_dict(diag)) # 可 JSON 序列化
|
||||
|
||||
诊断只读探测本地状态,不会触发 media-assets 下载。
|
||||
|
||||
自 **1.2.11** 起,`runtime_diagnostics_dict` 额外包含只读字段 `machine_power`(AC 供电 / 可选 nvidia-smi 限频信息)。探不到则 `probed=false`,**不会**因此把 health 标为 fatal。
|
||||
|
||||
### RPA 有界就绪等待与启动超时(1.2.11+)
|
||||
|
||||
```python
|
||||
from jiangchang_skill_core.rpa import poll_until
|
||||
from jiangchang_skill_core.rpa.errors import ERR_UI_NOT_READY, ERR_BROWSER_START_TIMEOUT
|
||||
|
||||
# 有界轮询:超时返回 timed_out,不抛异常;等待可响应宿主 pause/stop
|
||||
result = await poll_until(lambda: editor_visible, timeout_sec=30, interval_sec=0.5)
|
||||
if result.timed_out:
|
||||
raise RuntimeError(f"{ERR_UI_NOT_READY} 页面未在时限内就绪")
|
||||
|
||||
# 可选浏览器启动超时(默认关闭,与 1.2.10 行为一致)
|
||||
# await launch_persistent_browser(pw, profile_dir=..., timeout_ms=60_000)
|
||||
# 或环境变量 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000
|
||||
```
|
||||
|
||||
薄错误码(技能自行决定是否 fatal / retryable):
|
||||
|
||||
- `ERROR:UI_NOT_READY`
|
||||
- `ERROR:BROWSER_START_TIMEOUT`
|
||||
- `ERROR:ENV_DEGRADED`
|
||||
|
||||
### 录屏合成(`screencast`)
|
||||
|
||||
`run_screencast()` / `compose_video()` 通过 `jiangchang_skill_core.media_assets` 解析 ffmpeg 与背景音乐,**不使用系统 PATH 中的 `ffmpeg`**。
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "jiangchang-platform-kit"
|
||||
version = "1.2.10"
|
||||
version = "1.2.11"
|
||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
106
src/jiangchang_skill_core/machine_power.py
Normal file
106
src/jiangchang_skill_core/machine_power.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""只读机器供电 / GPU 限频探测(失败静默;不作为 hard gate)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _probe_ac_power_windows() -> dict[str, Any] | None:
|
||||
"""GetSystemPowerStatus;非 Windows 或失败返回 None。"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class SYSTEM_POWER_STATUS(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("ACLineStatus", wintypes.BYTE),
|
||||
("BatteryFlag", wintypes.BYTE),
|
||||
("BatteryLifePercent", wintypes.BYTE),
|
||||
("SystemStatusFlag", wintypes.BYTE),
|
||||
("BatteryLifeTime", wintypes.DWORD),
|
||||
("BatteryFullLifeTime", wintypes.DWORD),
|
||||
]
|
||||
|
||||
status = SYSTEM_POWER_STATUS()
|
||||
ok = ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status))
|
||||
if not ok:
|
||||
return None
|
||||
ac = int(status.ACLineStatus)
|
||||
pct = int(status.BatteryLifePercent)
|
||||
return {
|
||||
"ac_line_status": ac, # 0=offline, 1=online, 255=unknown
|
||||
"on_ac_power": True if ac == 1 else (False if ac == 0 else None),
|
||||
"battery_life_percent": None if pct > 100 else pct,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _probe_nvidia_throttle() -> dict[str, Any] | None:
|
||||
"""nvidia-smi 只读查询;无驱动/失败返回 None。"""
|
||||
smi = shutil.which("nvidia-smi")
|
||||
if not smi:
|
||||
return None
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
smi,
|
||||
"--query-gpu=name,enforced.power.limit,power.limit,clocks_throttle_reasons.active",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
line = (completed.stdout or "").strip().splitlines()
|
||||
if not line:
|
||||
return None
|
||||
# 多卡时只取第一张,足够做环境提示
|
||||
parts = [p.strip() for p in line[0].split(",")]
|
||||
if len(parts) < 4:
|
||||
return None
|
||||
|
||||
def _float_or_none(raw: str) -> float | None:
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
throttle = parts[3]
|
||||
return {
|
||||
"gpu_name": parts[0] or None,
|
||||
"enforced_power_limit_w": _float_or_none(parts[1]),
|
||||
"power_limit_w": _float_or_none(parts[2]),
|
||||
"clocks_throttle_reasons_active": throttle if throttle else None,
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def probe_machine_power() -> dict[str, Any]:
|
||||
"""汇总只读探测结果。字段缺失表示探不到,不视为错误。"""
|
||||
payload: dict[str, Any] = {
|
||||
"probed": False,
|
||||
"ac_power": None,
|
||||
"nvidia": None,
|
||||
}
|
||||
ac = _probe_ac_power_windows()
|
||||
nvidia = _probe_nvidia_throttle()
|
||||
if ac is not None:
|
||||
payload["ac_power"] = ac
|
||||
payload["probed"] = True
|
||||
if nvidia is not None:
|
||||
payload["nvidia"] = nvidia
|
||||
payload["probed"] = True
|
||||
return payload
|
||||
|
||||
|
||||
__all__ = ["probe_machine_power"]
|
||||
@@ -1,8 +1,8 @@
|
||||
"""浏览器 RPA 共享库:stealth、拟人操作、启动封装、HITL 等待、失败存证。"""
|
||||
"""浏览器 RPA 共享库:stealth、拟人操作、启动封装、HITL 等待、失败存证、有界就绪等待。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import anti_detect, artifacts, errors, human_login, stealth
|
||||
from . import anti_detect, artifacts, errors, human_login, stealth, wait
|
||||
|
||||
try:
|
||||
from .browser import launch_persistent_browser
|
||||
@@ -14,14 +14,20 @@ try:
|
||||
except ImportError:
|
||||
RpaVideoSession = None # type: ignore[misc, assignment]
|
||||
|
||||
from .wait import PollUntilResult, poll_until, poll_until_sync
|
||||
|
||||
__all__ = [
|
||||
"anti_detect",
|
||||
"artifacts",
|
||||
"errors",
|
||||
"human_login",
|
||||
"stealth",
|
||||
"wait",
|
||||
"launch_persistent_browser",
|
||||
"RpaVideoSession",
|
||||
"PollUntilResult",
|
||||
"poll_until",
|
||||
"poll_until_sync",
|
||||
# re-export common anti_detect helpers
|
||||
"random_delay",
|
||||
"human_delay_short",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .. import config
|
||||
from ..runtime_env import find_chrome_executable
|
||||
from .errors import ERR_BROWSER_START_TIMEOUT, ERR_MISSING_BROWSER
|
||||
from .stealth import (
|
||||
STEALTH_INIT_SCRIPT,
|
||||
persistent_context_launch_parts,
|
||||
@@ -15,6 +17,23 @@ from .stealth import (
|
||||
if TYPE_CHECKING:
|
||||
from playwright.async_api import BrowserContext
|
||||
|
||||
# 默认不设启动超时(保持 1.2.10 行为);可通过参数或环境变量开启。
|
||||
_ENV_LAUNCH_TIMEOUT_MS = "OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS"
|
||||
|
||||
|
||||
def _resolve_launch_timeout_ms(timeout_ms: int | None) -> int | None:
|
||||
if timeout_ms is not None:
|
||||
value = int(timeout_ms)
|
||||
return value if value > 0 else None
|
||||
raw = (config.get(_ENV_LAUNCH_TIMEOUT_MS, default="") or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
async def launch_persistent_browser(
|
||||
playwright,
|
||||
@@ -24,11 +43,17 @@ async def launch_persistent_browser(
|
||||
executable_path: str | None = None,
|
||||
headless: bool | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
timeout_ms: int | None = None,
|
||||
) -> "BrowserContext":
|
||||
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
||||
|
||||
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
||||
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
||||
|
||||
timeout_ms:
|
||||
可选启动超时(毫秒)。为 None 时读 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS;
|
||||
仍为空则不包裹超时(与历史行为一致)。超时抛 RuntimeError,前缀
|
||||
ERROR:BROWSER_START_TIMEOUT。本函数不做自动重试。
|
||||
"""
|
||||
if headless is None:
|
||||
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
||||
@@ -36,7 +61,7 @@ async def launch_persistent_browser(
|
||||
chrome = executable_path or find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
||||
f"{ERR_MISSING_BROWSER} 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
||||
)
|
||||
|
||||
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
|
||||
@@ -51,7 +76,22 @@ async def launch_persistent_browser(
|
||||
if ignore is not None:
|
||||
launch_kwargs["ignore_default_args"] = ignore
|
||||
|
||||
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
|
||||
launch_timeout_ms = _resolve_launch_timeout_ms(timeout_ms)
|
||||
try:
|
||||
if launch_timeout_ms is None:
|
||||
context = await playwright.chromium.launch_persistent_context(**launch_kwargs)
|
||||
else:
|
||||
context = await asyncio.wait_for(
|
||||
playwright.chromium.launch_persistent_context(**launch_kwargs),
|
||||
timeout=launch_timeout_ms / 1000.0,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise RuntimeError(
|
||||
f"{ERR_BROWSER_START_TIMEOUT} 浏览器启动超时"
|
||||
f"({launch_timeout_ms}ms)。机器限频/供电不足时易触发,可增大"
|
||||
f" {_ENV_LAUNCH_TIMEOUT_MS} 后重试。"
|
||||
) from exc
|
||||
|
||||
if stealth_enabled():
|
||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
return context
|
||||
|
||||
@@ -8,6 +8,11 @@ ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
|
||||
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
|
||||
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
|
||||
|
||||
# 就绪 / 环境类(与配置门禁、MISSING_BROWSER 区分;是否 fatal/retryable 由技能决定)
|
||||
ERR_UI_NOT_READY = "ERROR:UI_NOT_READY"
|
||||
ERR_BROWSER_START_TIMEOUT = "ERROR:BROWSER_START_TIMEOUT"
|
||||
ERR_ENV_DEGRADED = "ERROR:ENV_DEGRADED"
|
||||
|
||||
__all__ = [
|
||||
"ERR_REQUIRE_LOGIN",
|
||||
"ERR_LOGIN_TIMEOUT",
|
||||
@@ -16,4 +21,7 @@ __all__ = [
|
||||
"ERR_MISSING_BROWSER",
|
||||
"ERR_DEVICE_NOT_READY",
|
||||
"ERR_WINDOW_NOT_FOUND",
|
||||
"ERR_UI_NOT_READY",
|
||||
"ERR_BROWSER_START_TIMEOUT",
|
||||
"ERR_ENV_DEGRADED",
|
||||
]
|
||||
|
||||
112
src/jiangchang_skill_core/rpa/wait.py
Normal file
112
src/jiangchang_skill_core/rpa/wait.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""有界就绪等待:在超时内轮询条件,区分「还在加载」与「时间到仍未就绪」。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Union
|
||||
|
||||
from ..activity import interruptible_sleep, interruptible_sleep_sync
|
||||
|
||||
Predicate = Callable[[], Union[bool, Awaitable[bool]]]
|
||||
SyncPredicate = Callable[[], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollUntilResult:
|
||||
"""poll_until / poll_until_sync 的结果。"""
|
||||
|
||||
ok: bool
|
||||
elapsed_sec: float
|
||||
timed_out: bool = False
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
return self.ok
|
||||
|
||||
|
||||
async def _call_predicate(predicate: Predicate) -> bool:
|
||||
result = predicate()
|
||||
if inspect.isawaitable(result):
|
||||
result = await result # type: ignore[misc]
|
||||
return bool(result)
|
||||
|
||||
|
||||
async def poll_until(
|
||||
predicate: Predicate,
|
||||
*,
|
||||
timeout_sec: float,
|
||||
interval_sec: float = 0.5,
|
||||
) -> PollUntilResult:
|
||||
"""异步有界轮询,直到 ``predicate`` 为真或超时。
|
||||
|
||||
- 等待切片走 ``interruptible_sleep``,可响应宿主 pause/stop。
|
||||
- ``predicate`` 可为同步或 async 可调用对象;异常视为尚未就绪(继续轮询)。
|
||||
- 超时不抛异常,返回 ``timed_out=True``,由调用方映射错误码。
|
||||
"""
|
||||
timeout = max(0.0, float(timeout_sec))
|
||||
interval = max(0.05, float(interval_sec))
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
|
||||
while True:
|
||||
try:
|
||||
if await _call_predicate(predicate):
|
||||
return PollUntilResult(
|
||||
ok=True,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return PollUntilResult(
|
||||
ok=False,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=True,
|
||||
)
|
||||
await interruptible_sleep(min(interval, remaining))
|
||||
|
||||
|
||||
def poll_until_sync(
|
||||
predicate: SyncPredicate,
|
||||
*,
|
||||
timeout_sec: float,
|
||||
interval_sec: float = 0.5,
|
||||
) -> PollUntilResult:
|
||||
"""同步有界轮询(``interruptible_sleep_sync``)。"""
|
||||
timeout = max(0.0, float(timeout_sec))
|
||||
interval = max(0.05, float(interval_sec))
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
|
||||
while True:
|
||||
try:
|
||||
if bool(predicate()):
|
||||
return PollUntilResult(
|
||||
ok=True,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return PollUntilResult(
|
||||
ok=False,
|
||||
elapsed_sec=round(time.monotonic() - started, 3),
|
||||
timed_out=True,
|
||||
)
|
||||
interruptible_sleep_sync(min(interval, remaining))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PollUntilResult",
|
||||
"poll_until",
|
||||
"poll_until_sync",
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Mapping
|
||||
|
||||
import jiangchang_skill_core
|
||||
from . import config
|
||||
from .machine_power import probe_machine_power
|
||||
from .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
|
||||
from .runtime_env import platform_default_data_root
|
||||
|
||||
@@ -51,6 +52,8 @@ class RuntimeDiagnostics:
|
||||
|
||||
record_video_enabled: bool
|
||||
issues: tuple[RuntimeIssue, ...]
|
||||
# 只读机器信号(供电/限频);探不到为空 dict / probed=False,永不单独导致 fatal
|
||||
machine_power: dict[str, Any]
|
||||
|
||||
@property
|
||||
def has_fatal_issues(self) -> bool:
|
||||
@@ -258,6 +261,12 @@ def collect_runtime_diagnostics(
|
||||
)
|
||||
)
|
||||
|
||||
# 附加项:只读供电/限频;异常吞掉,不影响 health fatal
|
||||
try:
|
||||
machine_power = probe_machine_power()
|
||||
except Exception:
|
||||
machine_power = {"probed": False, "ac_power": None, "nvidia": None}
|
||||
|
||||
return RuntimeDiagnostics(
|
||||
skill_slug=skill_slug,
|
||||
python_executable=sys.executable,
|
||||
@@ -276,6 +285,7 @@ def collect_runtime_diagnostics(
|
||||
background_music_sample_path=str(music_sample) if music_sample else None,
|
||||
record_video_enabled=record_video_enabled,
|
||||
issues=tuple(issues),
|
||||
machine_power=machine_power,
|
||||
)
|
||||
|
||||
|
||||
@@ -297,7 +307,24 @@ def format_runtime_health_lines(diagnostics: RuntimeDiagnostics) -> list[str]:
|
||||
f"background_music_issue: {diagnostics.background_music_issue or ''}",
|
||||
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
|
||||
f"record_video_enabled: {diagnostics.record_video_enabled}",
|
||||
f"machine_power_probed: {bool((diagnostics.machine_power or {}).get('probed'))}",
|
||||
]
|
||||
power = diagnostics.machine_power or {}
|
||||
ac = power.get("ac_power") or {}
|
||||
if ac:
|
||||
lines.append(f"machine_ac_line_status: {ac.get('ac_line_status', '')}")
|
||||
lines.append(f"machine_on_ac_power: {ac.get('on_ac_power', '')}")
|
||||
lines.append(f"machine_battery_life_percent: {ac.get('battery_life_percent', '')}")
|
||||
nvidia = power.get("nvidia") or {}
|
||||
if nvidia:
|
||||
lines.append(f"machine_gpu_name: {nvidia.get('gpu_name', '')}")
|
||||
lines.append(
|
||||
f"machine_enforced_power_limit_w: {nvidia.get('enforced_power_limit_w', '')}"
|
||||
)
|
||||
lines.append(
|
||||
"machine_clocks_throttle_reasons_active: "
|
||||
f"{nvidia.get('clocks_throttle_reasons_active', '')}"
|
||||
)
|
||||
for issue in diagnostics.issues:
|
||||
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
|
||||
return lines
|
||||
@@ -321,6 +348,7 @@ def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
|
||||
"background_music_issue": diagnostics.background_music_issue,
|
||||
"background_music_sample_path": diagnostics.background_music_sample_path,
|
||||
"record_video_enabled": diagnostics.record_video_enabled,
|
||||
"machine_power": diagnostics.machine_power,
|
||||
"runtime_issues": [
|
||||
{"code": issue.code, "message": issue.message, "severity": issue.severity}
|
||||
for issue in diagnostics.issues
|
||||
|
||||
190
tests/test_rpa_wait_and_env.py
Normal file
190
tests/test_rpa_wait_and_env.py
Normal file
@@ -0,0 +1,190 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""poll_until / 薄错误码 / browser launch timeout / machine_power 探测。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from jiangchang_skill_core.machine_power import probe_machine_power
|
||||
from jiangchang_skill_core.rpa import errors
|
||||
from jiangchang_skill_core.rpa.browser import launch_persistent_browser
|
||||
from jiangchang_skill_core.rpa.wait import poll_until, poll_until_sync
|
||||
|
||||
|
||||
def test_poll_until_succeeds_when_predicate_becomes_true(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def _fake_sleep(seconds: float, *, chunk=None):
|
||||
del chunk
|
||||
sleeps.append(seconds)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.wait.interruptible_sleep",
|
||||
_fake_sleep,
|
||||
)
|
||||
state = {"n": 0}
|
||||
|
||||
def ready() -> bool:
|
||||
state["n"] += 1
|
||||
return state["n"] >= 3
|
||||
|
||||
result = asyncio.run(poll_until(ready, timeout_sec=2.0, interval_sec=0.1))
|
||||
assert result.ok is True
|
||||
assert result.timed_out is False
|
||||
assert state["n"] == 3
|
||||
assert sleeps
|
||||
|
||||
|
||||
def test_poll_until_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def _fake_sleep(seconds: float, *, chunk=None):
|
||||
del chunk, seconds
|
||||
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.wait.interruptible_sleep",
|
||||
_fake_sleep,
|
||||
)
|
||||
result = asyncio.run(poll_until(lambda: False, timeout_sec=0.01, interval_sec=0.01))
|
||||
assert result.ok is False
|
||||
assert result.timed_out is True
|
||||
|
||||
|
||||
def test_poll_until_sync_ok(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.wait.interruptible_sleep_sync",
|
||||
lambda seconds, *, chunk=None: None,
|
||||
)
|
||||
assert poll_until_sync(lambda: True, timeout_sec=1.0).ok is True
|
||||
|
||||
|
||||
def test_thin_error_codes_exist() -> None:
|
||||
assert errors.ERR_UI_NOT_READY == "ERROR:UI_NOT_READY"
|
||||
assert errors.ERR_BROWSER_START_TIMEOUT == "ERROR:BROWSER_START_TIMEOUT"
|
||||
assert errors.ERR_ENV_DEGRADED == "ERROR:ENV_DEGRADED"
|
||||
assert errors.ERR_MISSING_BROWSER == "ERROR:MISSING_BROWSER"
|
||||
|
||||
|
||||
def test_launch_persistent_browser_timeout_maps_code(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.find_chrome_executable",
|
||||
lambda: r"C:\fake\chrome.exe",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.persistent_context_launch_parts",
|
||||
lambda extra_args=None: ([], None),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.stealth_enabled",
|
||||
lambda: False,
|
||||
)
|
||||
|
||||
async def _hang(**kwargs):
|
||||
del kwargs
|
||||
await asyncio.sleep(10)
|
||||
raise AssertionError("should have been cancelled by wait_for")
|
||||
|
||||
playwright = MagicMock()
|
||||
playwright.chromium.launch_persistent_context = _hang
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(RuntimeError) as ctx:
|
||||
await launch_persistent_browser(
|
||||
playwright,
|
||||
profile_dir=r"C:\fake\profile",
|
||||
timeout_ms=50,
|
||||
)
|
||||
assert str(ctx.value).startswith(errors.ERR_BROWSER_START_TIMEOUT)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_launch_persistent_browser_missing_browser(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.rpa.browser.find_chrome_executable",
|
||||
lambda: None,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(RuntimeError) as ctx:
|
||||
await launch_persistent_browser(MagicMock(), profile_dir=r"C:\fake\profile")
|
||||
assert str(ctx.value).startswith(errors.ERR_MISSING_BROWSER)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_probe_machine_power_shape_and_no_raise() -> None:
|
||||
payload = probe_machine_power()
|
||||
assert "probed" in payload
|
||||
assert "ac_power" in payload
|
||||
assert "nvidia" in payload
|
||||
assert isinstance(payload["probed"], bool)
|
||||
|
||||
|
||||
def test_collect_runtime_diagnostics_includes_machine_power_without_fatal(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from jiangchang_skill_core import media_assets as ma
|
||||
from jiangchang_skill_core.runtime_diagnostics import (
|
||||
collect_runtime_diagnostics,
|
||||
runtime_diagnostics_dict,
|
||||
)
|
||||
|
||||
env = {"JIANGCHANG_DATA_ROOT": str(tmp_path / "data"), "OPENCLAW_RECORD_VIDEO": "0"}
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_media_assets",
|
||||
lambda *a, **k: ma.MediaAssetsStatus(
|
||||
root=tmp_path,
|
||||
exists=True,
|
||||
ready=True,
|
||||
source="local",
|
||||
warnings=[],
|
||||
ffmpeg_path=tmp_path / "ffmpeg.exe",
|
||||
music_dir=tmp_path / "music",
|
||||
music_ready=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_background_music",
|
||||
lambda *a, **k: {
|
||||
"music_root": str(tmp_path / "music"),
|
||||
"mp3_count": 1,
|
||||
"usable_count": 1,
|
||||
"sample_path": str(tmp_path / "music" / "a.mp3"),
|
||||
"issue": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics._platform_kit_version",
|
||||
lambda: "1.2.11",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"jiangchang_skill_core.runtime_diagnostics.probe_machine_power",
|
||||
lambda: {
|
||||
"probed": True,
|
||||
"ac_power": {
|
||||
"ac_line_status": 1,
|
||||
"on_ac_power": True,
|
||||
"battery_life_percent": 100,
|
||||
},
|
||||
"nvidia": {
|
||||
"gpu_name": "Fake",
|
||||
"enforced_power_limit_w": 140.0,
|
||||
"power_limit_w": 140.0,
|
||||
"clocks_throttle_reasons_active": "None",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
diag = collect_runtime_diagnostics(skill_slug="demo", env=env)
|
||||
payload = runtime_diagnostics_dict(diag)
|
||||
assert payload["machine_power"]["probed"] is True
|
||||
assert diag.has_fatal_issues is False
|
||||
@@ -357,6 +357,7 @@ def test_runtime_diagnostics_frozen_dataclass() -> None:
|
||||
background_music_sample_path=None,
|
||||
record_video_enabled=False,
|
||||
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
||||
machine_power={"probed": False, "ac_power": None, "nvidia": None},
|
||||
)
|
||||
assert diag.has_fatal_issues is False
|
||||
assert diag.issue_codes() == ["ffmpeg_unavailable"]
|
||||
@@ -381,6 +382,7 @@ def test_format_runtime_health_lines_issue_separator_ascii() -> None:
|
||||
background_music_sample_path=None,
|
||||
record_video_enabled=False,
|
||||
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
||||
machine_power={"probed": False, "ac_power": None, "nvidia": None},
|
||||
)
|
||||
lines = format_runtime_health_lines(diag)
|
||||
issue_lines = [line for line in lines if line.startswith("runtime_issue[")]
|
||||
|
||||
Reference in New Issue
Block a user