From c9f5c37af81767802d97201424c89aa9efefd92f Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Wed, 12 Aug 2026 10:36:32 +0800 Subject: [PATCH] feat(rpa): add poll_until, env error codes, optional launch timeout (1.2.11) 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 --- README.md | 26 ++- pyproject.toml | 2 +- src/jiangchang_skill_core/machine_power.py | 106 ++++++++++ src/jiangchang_skill_core/rpa/__init__.py | 10 +- src/jiangchang_skill_core/rpa/browser.py | 44 +++- src/jiangchang_skill_core/rpa/errors.py | 8 + src/jiangchang_skill_core/rpa/wait.py | 112 +++++++++++ .../runtime_diagnostics.py | 28 +++ tests/test_rpa_wait_and_env.py | 190 ++++++++++++++++++ tests/test_runtime_diagnostics.py | 2 + 10 files changed, 522 insertions(+), 6 deletions(-) create mode 100644 src/jiangchang_skill_core/machine_power.py create mode 100644 src/jiangchang_skill_core/rpa/wait.py create mode 100644 tests/test_rpa_wait_and_env.py diff --git a/README.md b/README.md index 4580183..d73ca4a 100644 --- a/README.md +++ b/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`**。 diff --git a/pyproject.toml b/pyproject.toml index a4d74e8..77dbff8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/jiangchang_skill_core/machine_power.py b/src/jiangchang_skill_core/machine_power.py new file mode 100644 index 0000000..50e85ba --- /dev/null +++ b/src/jiangchang_skill_core/machine_power.py @@ -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"] diff --git a/src/jiangchang_skill_core/rpa/__init__.py b/src/jiangchang_skill_core/rpa/__init__.py index 3979200..b4e5509 100644 --- a/src/jiangchang_skill_core/rpa/__init__.py +++ b/src/jiangchang_skill_core/rpa/__init__.py @@ -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", diff --git a/src/jiangchang_skill_core/rpa/browser.py b/src/jiangchang_skill_core/rpa/browser.py index 9e1d9a4..ccfe0e8 100644 --- a/src/jiangchang_skill_core/rpa/browser.py +++ b/src/jiangchang_skill_core/rpa/browser.py @@ -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 diff --git a/src/jiangchang_skill_core/rpa/errors.py b/src/jiangchang_skill_core/rpa/errors.py index 151bbff..33dfd2d 100644 --- a/src/jiangchang_skill_core/rpa/errors.py +++ b/src/jiangchang_skill_core/rpa/errors.py @@ -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", ] diff --git a/src/jiangchang_skill_core/rpa/wait.py b/src/jiangchang_skill_core/rpa/wait.py new file mode 100644 index 0000000..d06fb52 --- /dev/null +++ b/src/jiangchang_skill_core/rpa/wait.py @@ -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", +] diff --git a/src/jiangchang_skill_core/runtime_diagnostics.py b/src/jiangchang_skill_core/runtime_diagnostics.py index 0991f00..c24140a 100644 --- a/src/jiangchang_skill_core/runtime_diagnostics.py +++ b/src/jiangchang_skill_core/runtime_diagnostics.py @@ -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 diff --git a/tests/test_rpa_wait_and_env.py b/tests/test_rpa_wait_and_env.py new file mode 100644 index 0000000..cc03a7c --- /dev/null +++ b/tests/test_rpa_wait_and_env.py @@ -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 diff --git a/tests/test_runtime_diagnostics.py b/tests/test_runtime_diagnostics.py index e1be221..46d1858 100644 --- a/tests/test_runtime_diagnostics.py +++ b/tests/test_runtime_diagnostics.py @@ -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[")]