Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28abd3509a | |||
| c9f5c37af8 | |||
| 065d341320 | |||
| 24384d70ba | |||
| 17ac1b4e11 | |||
| 84157758e6 |
28
README.md
28
README.md
@@ -12,7 +12,7 @@ pip install jiangchang-platform-kit --index-url https://git.jc2009.com/api/packa
|
|||||||
|
|
||||||
## 3. 包含的模块
|
## 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_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`。
|
- **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,32 @@ print(runtime_diagnostics_dict(diag)) # 可 JSON 序列化
|
|||||||
|
|
||||||
诊断只读探测本地状态,不会触发 media-assets 下载。
|
诊断只读探测本地状态,不会触发 media-assets 下载。
|
||||||
|
|
||||||
|
自 **1.2.12** 起,统一文件日志 `jiangchang.log` 的时间戳为 `YYYY-MM-DD HH:MM:SS`(空格分隔,不再使用 ISO 的 `T`)。宿主运行日志解析需同时兼容新旧两种格式。
|
||||||
|
|
||||||
|
自 **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`)
|
### 录屏合成(`screencast`)
|
||||||
|
|
||||||
`run_screencast()` / `compose_video()` 通过 `jiangchang_skill_core.media_assets` 解析 ffmpeg 与背景音乐,**不使用系统 PATH 中的 `ffmpeg`**。
|
`run_screencast()` / `compose_video()` 通过 `jiangchang_skill_core.media_assets` 解析 ffmpeg 与背景音乐,**不使用系统 PATH 中的 `ffmpeg`**。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jiangchang-platform-kit"
|
name = "jiangchang-platform-kit"
|
||||||
version = "1.2.6"
|
version = "1.2.12"
|
||||||
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树"
|
description = "匠厂平台共享组件:Skill 实体 SDK + 桌面应用自动化 SDK + 桌面 UIA 暖树"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# jiangchang_desktop_wake 验收(ensure 主路径,1.2.6+)
|
# jiangchang_desktop_wake 验收(ensure 主路径,1.2.10+)
|
||||||
|
|
||||||
正式对外路径是 **`ensure`**(内部:`activate` → `desktop-wake --mode n2-wake` → `probe`)。
|
正式对外路径是 **`ensure`**(内部:`activate` → `desktop-wake --mode n2-wake` → `probe`)。
|
||||||
`n2-wake`:**仅 OBJID_CLIENT** 走 DuiLib;不做进程内 FindAll、不同步 GETOBJECT、不 SPI 读屏;判暖只看进程外 probe。
|
`n2-wake`:**仅 OBJID_CLIENT** 走 DuiLib;不做进程内 FindAll、不同步 GETOBJECT、不 SPI 读屏;判暖只看进程外 probe。
|
||||||
`c2` / `inject` 仅诊断用。
|
`c2` / `inject` 仅诊断用。
|
||||||
|
|
||||||
| profile | 窗 | 1.2.6 |
|
| profile | 窗 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `wecom` | `WeWorkWindow` | 主窗(原有) |
|
| `wecom` | `WeWorkWindow` | 主窗(原有) |
|
||||||
| `wecom_search` | `SearchResultWindow2` | 搜索联系人浮层;不 Maximize;markers=`search_result`,`user_item` |
|
| `wecom_search` | `SearchResultWindow2` | 搜索浮层;**不对浮层 Maximize**;n2-wake **子类化浮层本身**(多 HWND;1.2.10+) |
|
||||||
|
|
||||||
## 0. 编译(企微先完全退出,含托盘)
|
## 0. 编译(企微先完全退出,含托盘)
|
||||||
|
|
||||||
@@ -77,10 +77,11 @@ python -m jiangchang_desktop_wake probe --profile wecom
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## C. 搜索浮层 wecom_search(1.2.6+)
|
## C. 搜索浮层 wecom_search(1.2.10+)
|
||||||
|
|
||||||
1. 打开企微主窗并登录;在搜索框输入联系人,**保持搜索结果浮层打开**(标题/类名 `SearchResultWindow2`)。
|
1. **建议先** `ensure --profile wecom` 暖主窗(可与浮层分步;验证多 HWND)。
|
||||||
2. 验收:
|
2. 在搜索框输入联系人,**保持搜索结果浮层打开**(类名 `SearchResultWindow2`)。
|
||||||
|
3. 验收:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit
|
cd D:\OpenClaw\client-jiangchang\jiangchang-platform-kit
|
||||||
@@ -93,7 +94,9 @@ python -m jiangchang_desktop_wake probe --profile wecom_search
|
|||||||
| 期望 | 说明 |
|
| 期望 | 说明 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| ensure 退出码 `0` | 浮层已暖 |
|
| ensure 退出码 `0` | 浮层已暖 |
|
||||||
| `warm=true` | markers 含 `search_result` / `user_item`(或 findall 足够) |
|
| `warm=true` | markers 含 `search_result` / `user_item`;findall 从 ~2 升到几十/上百 |
|
||||||
|
| `%TEMP%\jiangchang_desktop_wake_subclass.txt` | 含浮层 hwnd,`ok=1`(主窗已暖后再 ensure 浮层可 `slots>=2`) |
|
||||||
|
| 企微 UI | 浮层**不**被 Maximize、**不崩**、不无故关闭 |
|
||||||
| `WINDOW_NOT_FOUND` | 浮层未打开——先展开搜索结果再测 |
|
| `WINDOW_NOT_FOUND` | 浮层未打开——先展开搜索结果再测 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ print(rs.warm, rs.probe_after.findall_count, rs.probe_after.markers_found)
|
|||||||
| profile | 窗 | 说明 |
|
| profile | 窗 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `wecom` | `WeWorkWindow` | 主窗;markers=`con_op`,`searchedit` |
|
| `wecom` | `WeWorkWindow` | 主窗;markers=`con_op`,`searchedit` |
|
||||||
| `wecom_search` | `SearchResultWindow2` | 搜索联系人浮层;markers=`search_result`,`user_item`;不 Maximize |
|
| `wecom_search` | `SearchResultWindow2` | 搜索浮层;markers=`search_result`,`user_item`;不 Maximize;**n2-wake 对浮层子类化**(1.2.10+) |
|
||||||
|
|
||||||
| CLI | 退出码 |
|
| CLI | 退出码 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -19,6 +19,7 @@ namespace JiangchangDesktopWake
|
|||||||
private const uint OBJID_CLIENT = 0xFFFFFFFC;
|
private const uint OBJID_CLIENT = 0xFFFFFFFC;
|
||||||
private const uint OBJID_WINDOW = 0x00000000;
|
private const uint OBJID_WINDOW = 0x00000000;
|
||||||
private const int SW_RESTORE = 9;
|
private const int SW_RESTORE = 9;
|
||||||
|
private const int SW_SHOW = 5;
|
||||||
private const int SW_MAXIMIZE = 3;
|
private const int SW_MAXIMIZE = 3;
|
||||||
private const int WH_CALLWNDPROC = 4;
|
private const int WH_CALLWNDPROC = 4;
|
||||||
private const uint PROCESS_CREATE_THREAD = 0x0002;
|
private const uint PROCESS_CREATE_THREAD = 0x0002;
|
||||||
@@ -42,6 +43,9 @@ namespace JiangchangDesktopWake
|
|||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern bool IsIconic(IntPtr hWnd);
|
private static extern bool IsIconic(IntPtr hWnd);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
@@ -165,6 +169,8 @@ namespace JiangchangDesktopWake
|
|||||||
if (!doWakeProvider)
|
if (!doWakeProvider)
|
||||||
EnableScreenReaderFlag();
|
EnableScreenReaderFlag();
|
||||||
|
|
||||||
|
// 1.2.10:允许对 SearchResultWindow2 n2-wake(本机验证可暖且不崩);
|
||||||
|
// 仍靠 ShouldMaximize=false,禁止 Maximize 打掉浮层。
|
||||||
if (doN2)
|
if (doN2)
|
||||||
{
|
{
|
||||||
string n2Detail;
|
string n2Detail;
|
||||||
@@ -578,12 +584,32 @@ namespace JiangchangDesktopWake
|
|||||||
return new IntPtr(Convert.ToInt64(s, 16));
|
return new IntPtr(Convert.ToInt64(s, 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 主窗可 Maximize;SearchResultWindow2 等浮层禁止 Maximize(会撑爆/关掉浮层)。
|
||||||
|
/// </summary>
|
||||||
|
private static bool ShouldMaximize(IntPtr hwnd)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(256);
|
||||||
|
if (GetClassName(hwnd, sb, sb.Capacity) <= 0) return true;
|
||||||
|
string cls = sb.ToString();
|
||||||
|
if (string.Equals(cls, "SearchResultWindow2", StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
if (cls.IndexOf("Shadow", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static void Activate(IntPtr hwnd)
|
private static void Activate(IntPtr hwnd)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
|
if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE);
|
||||||
else ShowWindow(hwnd, SW_MAXIMIZE);
|
else if (ShouldMaximize(hwnd)) ShowWindow(hwnd, SW_MAXIMIZE);
|
||||||
|
else ShowWindow(hwnd, SW_SHOW);
|
||||||
SetForegroundWindow(hwnd);
|
SetForegroundWindow(hwnd);
|
||||||
Thread.Sleep(250);
|
Thread.Sleep(250);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
* - UiaRootObjectId 等原样交给旧 WndProc(禁止用 MSAA Lresult 冒充 UIA root)
|
* - UiaRootObjectId 等原样交给旧 WndProc(禁止用 MSAA Lresult 冒充 UIA root)
|
||||||
* - 不做进程内 UIAutomation FindAll;不做同步 SendMessage(GETOBJECT)
|
* - 不做进程内 UIAutomation FindAll;不做同步 SendMessage(GETOBJECT)
|
||||||
* - 重入守卫;判暖交给进程外 probe
|
* - 重入守卫;判暖交给进程外 probe
|
||||||
|
* 1.2.8:支持多 HWND 子类化(主窗 + SearchResultWindow2),禁止「全局只装一次」。
|
||||||
|
* 1.2.10:恢复对 SearchResultWindow2 子类化暖树(本机验证 2→122 且不崩);仍禁止 Maximize(exe 侧)。
|
||||||
*/
|
*/
|
||||||
#define WIN32_LEAN_AND_MEAN
|
#define WIN32_LEAN_AND_MEAN
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -40,16 +42,25 @@
|
|||||||
#define WM_JC_WAKE (WM_APP + 0x51)
|
#define WM_JC_WAKE (WM_APP + 0x51)
|
||||||
#define WM_JC_QUIT (WM_APP + 0x52)
|
#define WM_JC_QUIT (WM_APP + 0x52)
|
||||||
#define JC_SUBCLASS_ID 0x4A435701u
|
#define JC_SUBCLASS_ID 0x4A435701u
|
||||||
|
/* 主窗 + 搜索浮层等可能各装一次;禁止全局「只装一次」漏掉 SearchResultWindow2 */
|
||||||
|
#define JC_MAX_SUBCLASS 8
|
||||||
|
|
||||||
static HMODULE g_self = NULL;
|
static HMODULE g_self = NULL;
|
||||||
static HWND g_hwnd = NULL;
|
static HWND g_hwnd = NULL;
|
||||||
static WNDPROC g_oldProc = NULL;
|
static WNDPROC g_oldProc = NULL;
|
||||||
static WNDPROC g_duiOldProc = NULL;
|
|
||||||
static volatile LONG g_getobjectHits = 0;
|
static volatile LONG g_getobjectHits = 0;
|
||||||
static volatile LONG g_hookHits = 0;
|
static volatile LONG g_hookHits = 0;
|
||||||
static volatile LONG g_duiHits = 0;
|
static volatile LONG g_duiHits = 0;
|
||||||
static volatile LONG g_reenterSkip = 0;
|
static volatile LONG g_reenterSkip = 0;
|
||||||
static volatile LONG g_subclassInstalled = 0;
|
|
||||||
|
typedef struct JcSubEntry {
|
||||||
|
HWND hwnd;
|
||||||
|
WNDPROC oldProc; /* SetWindowLongPtr 路径;SetWindowSubclass 时为 NULL */
|
||||||
|
int useSubclassApi;
|
||||||
|
} JcSubEntry;
|
||||||
|
|
||||||
|
static JcSubEntry g_subs[JC_MAX_SUBCLASS];
|
||||||
|
static volatile LONG g_subCount = 0;
|
||||||
|
|
||||||
static HANDLE g_staThread = NULL;
|
static HANDLE g_staThread = NULL;
|
||||||
static HANDLE g_staReady = NULL;
|
static HANDLE g_staReady = NULL;
|
||||||
@@ -209,6 +220,16 @@ static LRESULT HandleGetObject(
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static JcSubEntry* FindSubEntry(HWND hwnd)
|
||||||
|
{
|
||||||
|
LONG n = g_subCount;
|
||||||
|
for (LONG i = 0; i < n && i < JC_MAX_SUBCLASS; i++) {
|
||||||
|
if (g_subs[i].hwnd == hwnd)
|
||||||
|
return &g_subs[i];
|
||||||
|
}
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
static LRESULT CALLBACK DuiSubclassProc(
|
static LRESULT CALLBACK DuiSubclassProc(
|
||||||
HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
|
HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam,
|
||||||
UINT_PTR uIdSubclass, DWORD_PTR dwRefData)
|
UINT_PTR uIdSubclass, DWORD_PTR dwRefData)
|
||||||
@@ -222,27 +243,46 @@ static LRESULT CALLBACK DuiSubclassProc(
|
|||||||
|
|
||||||
static LRESULT CALLBACK DuiWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
|
static LRESULT CALLBACK DuiWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
|
||||||
{
|
{
|
||||||
|
JcSubEntry* e = FindSubEntry(hwnd);
|
||||||
|
WNDPROC old = e ? e->oldProc : NULL;
|
||||||
if (msg == WM_GETOBJECT)
|
if (msg == WM_GETOBJECT)
|
||||||
return HandleGetObject(hwnd, wParam, lParam, g_duiOldProc, false);
|
return HandleGetObject(hwnd, wParam, lParam, old, false);
|
||||||
if (g_duiOldProc)
|
if (old)
|
||||||
return CallWindowProcW(g_duiOldProc, hwnd, msg, wParam, lParam);
|
return CallWindowProcW(old, hwnd, msg, wParam, lParam);
|
||||||
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
return DefWindowProcW(hwnd, msg, wParam, lParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool InstallDuiSubclass(HWND hwnd)
|
static bool InstallDuiSubclass(HWND hwnd)
|
||||||
{
|
{
|
||||||
if (!hwnd || !IsWindow(hwnd)) return false;
|
if (!hwnd || !IsWindow(hwnd)) return false;
|
||||||
if (InterlockedCompareExchange(&g_subclassInstalled, 1, 0) != 0)
|
if (FindSubEntry(hwnd))
|
||||||
return true;
|
return true;
|
||||||
|
if (g_subCount >= JC_MAX_SUBCLASS) {
|
||||||
|
WriteMarker("jiangchang_desktop_wake_subclass.txt",
|
||||||
|
"err=subclass_slots_full\r\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
ResolveDuiLib();
|
ResolveDuiLib();
|
||||||
void* userdata = (void*)GetWindowLongPtrW(hwnd, GWLP_USERDATA);
|
void* userdata = (void*)GetWindowLongPtrW(hwnd, GWLP_USERDATA);
|
||||||
|
|
||||||
|
/* 先占槽再换 WndProc,避免 GETOBJECT 抢在 Remember 之前 */
|
||||||
|
LONG idx = InterlockedIncrement(&g_subCount) - 1;
|
||||||
|
if (idx < 0 || idx >= JC_MAX_SUBCLASS) {
|
||||||
|
InterlockedDecrement(&g_subCount);
|
||||||
|
WriteMarker("jiangchang_desktop_wake_subclass.txt",
|
||||||
|
"err=subclass_slots_full\r\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
g_subs[idx].hwnd = hwnd;
|
||||||
|
g_subs[idx].oldProc = NULL;
|
||||||
|
g_subs[idx].useSubclassApi = 0;
|
||||||
|
|
||||||
SetLastError(0);
|
SetLastError(0);
|
||||||
g_duiOldProc = (WNDPROC)SetWindowLongPtrW(
|
WNDPROC oldProc = (WNDPROC)SetWindowLongPtrW(
|
||||||
hwnd, GWLP_WNDPROC, (LONG_PTR)DuiWndProc);
|
hwnd, GWLP_WNDPROC, (LONG_PTR)DuiWndProc);
|
||||||
DWORD gle = GetLastError();
|
DWORD gle = GetLastError();
|
||||||
BOOL ok = (g_duiOldProc != NULL);
|
BOOL ok = (oldProc != NULL);
|
||||||
const char* method = "SetWindowLongPtr";
|
const char* method = "SetWindowLongPtr";
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -255,17 +295,22 @@ static bool InstallDuiSubclass(HWND hwnd)
|
|||||||
ok = SetWindowSubclass(hwnd, DuiSubclassProc, JC_SUBCLASS_ID, 0);
|
ok = SetWindowSubclass(hwnd, DuiSubclassProc, JC_SUBCLASS_ID, 0);
|
||||||
gle = GetLastError();
|
gle = GetLastError();
|
||||||
method = "SetWindowSubclass";
|
method = "SetWindowSubclass";
|
||||||
|
g_subs[idx].useSubclassApi = 1;
|
||||||
|
oldProc = NULL;
|
||||||
|
} else {
|
||||||
|
g_subs[idx].oldProc = oldProc;
|
||||||
}
|
}
|
||||||
|
|
||||||
char line[512];
|
char line[512];
|
||||||
sprintf_s(line,
|
sprintf_s(line,
|
||||||
"hwnd=%p ok=%d gle=%lu method=%s userdata=%p "
|
"hwnd=%p ok=%d gle=%lu method=%s userdata=%p "
|
||||||
"duilib=%p getacc=%p onget=%p\r\n",
|
"duilib=%p getacc=%p onget=%p slots=%ld\r\n",
|
||||||
hwnd, (int)ok, gle, method, userdata,
|
hwnd, (int)ok, gle, method, userdata,
|
||||||
g_duilib, g_fnGetAccessible, g_fnOnGetObject);
|
g_duilib, g_fnGetAccessible, g_fnOnGetObject, (long)g_subCount);
|
||||||
WriteMarker("jiangchang_desktop_wake_subclass.txt", line);
|
WriteMarker("jiangchang_desktop_wake_subclass.txt", line);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
InterlockedExchange(&g_subclassInstalled, 0);
|
g_subs[idx].hwnd = NULL;
|
||||||
|
InterlockedDecrement(&g_subCount);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ _BUILTIN = {
|
|||||||
},
|
},
|
||||||
"wecom_search": {
|
"wecom_search": {
|
||||||
"id": "wecom_search",
|
"id": "wecom_search",
|
||||||
"description": "企业微信搜索结果浮层 SearchResultWindow2",
|
"description": "企业微信搜索结果浮层 SearchResultWindow2;n2-wake 对浮层子类化(1.2.10+),禁止 Maximize",
|
||||||
"window_class": "SearchResultWindow2",
|
"window_class": "SearchResultWindow2",
|
||||||
"warm_min_findall": 20,
|
"warm_min_findall": 20,
|
||||||
"warm_markers": ["search_result", "user_item"],
|
"warm_markers": ["search_result", "user_item"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"id": "wecom_search",
|
"id": "wecom_search",
|
||||||
"description": "企业微信搜索结果浮层 SearchResultWindow2(联系人列表 user_item)",
|
"description": "企业微信搜索结果浮层 SearchResultWindow2;n2-wake 对浮层子类化暖树(1.2.10+),禁止 Maximize",
|
||||||
"window_class": "SearchResultWindow2",
|
"window_class": "SearchResultWindow2",
|
||||||
"warm_min_findall": 20,
|
"warm_min_findall": 20,
|
||||||
"warm_markers": ["search_result", "user_item"],
|
"warm_markers": ["search_result", "user_item"],
|
||||||
|
|||||||
@@ -29,12 +29,16 @@ class Profile:
|
|||||||
native_args: list[str] = field(default_factory=list)
|
native_args: list[str] = field(default_factory=list)
|
||||||
# 浮层窗(如搜索结果)禁止 Maximize,避免打乱搜索态
|
# 浮层窗(如搜索结果)禁止 Maximize,避免打乱搜索态
|
||||||
activate_maximize: bool = True
|
activate_maximize: bool = True
|
||||||
|
# native_helper / n2-wake 目标窗 class;空则用 probe 窗本身。
|
||||||
|
# 1.2.10+ wecom_search 留空(对浮层自身 n2-wake);勿改注主窗。
|
||||||
|
native_hwnd_class: Optional[str] = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> "Profile":
|
def from_dict(cls, data: dict[str, Any]) -> "Profile":
|
||||||
maximize = data.get("activate_maximize")
|
maximize = data.get("activate_maximize")
|
||||||
if maximize is None:
|
if maximize is None:
|
||||||
maximize = True
|
maximize = True
|
||||||
|
native_cls = data.get("native_hwnd_class")
|
||||||
return cls(
|
return cls(
|
||||||
id=str(data.get("id") or "unknown"),
|
id=str(data.get("id") or "unknown"),
|
||||||
description=str(data.get("description") or ""),
|
description=str(data.get("description") or ""),
|
||||||
@@ -55,6 +59,7 @@ class Profile:
|
|||||||
native_helper=data.get("native_helper"),
|
native_helper=data.get("native_helper"),
|
||||||
native_args=list(data.get("native_args") or []),
|
native_args=list(data.get("native_args") or []),
|
||||||
activate_maximize=bool(maximize),
|
activate_maximize=bool(maximize),
|
||||||
|
native_hwnd_class=str(native_cls).strip() if native_cls else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ from .probe import probe
|
|||||||
from .profiles import load_profile
|
from .profiles import load_profile
|
||||||
from .types import Profile, WakeStepResult
|
from .types import Profile, WakeStepResult
|
||||||
from . import uia_backend
|
from . import uia_backend
|
||||||
from .window import activate_hwnd, find_hwnd_for_profile, require_windows
|
from .window import (
|
||||||
|
activate_hwnd,
|
||||||
|
find_hwnd_for_profile,
|
||||||
|
find_same_pid_hwnd_by_class,
|
||||||
|
get_window_pid,
|
||||||
|
require_windows,
|
||||||
|
)
|
||||||
|
|
||||||
_logger = logging.getLogger("jiangchang_desktop_wake.wake")
|
_logger = logging.getLogger("jiangchang_desktop_wake.wake")
|
||||||
|
|
||||||
@@ -23,6 +29,48 @@ _logger = logging.getLogger("jiangchang_desktop_wake.wake")
|
|||||||
StrategyFn = Callable[[int, Profile], Tuple[str, str]]
|
StrategyFn = Callable[[int, Profile], Tuple[str, str]]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_native_hwnd(probe_hwnd: int, profile: Profile) -> int:
|
||||||
|
"""native_helper 实际注入 hwnd。
|
||||||
|
|
||||||
|
若 profile.native_hwnd_class 已设:优先同 PID 下该 class 窗(如浮层 probe、主窗注入)。
|
||||||
|
找不到则回退 probe_hwnd,并打 warning。
|
||||||
|
"""
|
||||||
|
cls = (getattr(profile, "native_hwnd_class", None) or "").strip()
|
||||||
|
if not cls:
|
||||||
|
return int(probe_hwnd)
|
||||||
|
try:
|
||||||
|
pid = get_window_pid(int(probe_hwnd))
|
||||||
|
hit = find_same_pid_hwnd_by_class(pid=pid, class_name=cls)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning(
|
||||||
|
"resolve_native_hwnd failed profile=%s probe=%s err=%s; fallback probe",
|
||||||
|
profile.id,
|
||||||
|
probe_hwnd,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return int(probe_hwnd)
|
||||||
|
if not hit:
|
||||||
|
_logger.warning(
|
||||||
|
"resolve_native_hwnd miss profile=%s native_hwnd_class=%s pid=%s "
|
||||||
|
"probe=%s; fallback probe (may crash popup UI)",
|
||||||
|
profile.id,
|
||||||
|
cls,
|
||||||
|
pid,
|
||||||
|
probe_hwnd,
|
||||||
|
)
|
||||||
|
return int(probe_hwnd)
|
||||||
|
if int(hit) != int(probe_hwnd):
|
||||||
|
_logger.info(
|
||||||
|
"native_hwnd redirect profile=%s probe=%s -> inject=%s class=%s pid=%s",
|
||||||
|
profile.id,
|
||||||
|
probe_hwnd,
|
||||||
|
hit,
|
||||||
|
cls,
|
||||||
|
pid,
|
||||||
|
)
|
||||||
|
return int(hit)
|
||||||
|
|
||||||
|
|
||||||
def _strategy_activate(hwnd: int, profile: Profile) -> Tuple[str, str]:
|
def _strategy_activate(hwnd: int, profile: Profile) -> Tuple[str, str]:
|
||||||
maximize = bool(getattr(profile, "activate_maximize", True))
|
maximize = bool(getattr(profile, "activate_maximize", True))
|
||||||
activate_hwnd(hwnd, maximize=maximize)
|
activate_hwnd(hwnd, maximize=maximize)
|
||||||
@@ -152,6 +200,7 @@ def wake(
|
|||||||
"""
|
"""
|
||||||
require_windows()
|
require_windows()
|
||||||
prof = load_profile(profile) if isinstance(profile, str) else profile
|
prof = load_profile(profile) if isinstance(profile, str) else profile
|
||||||
|
# probe / activate 目标(如 SearchResultWindow2)
|
||||||
h = find_hwnd_for_profile(prof, hwnd=hwnd)
|
h = find_hwnd_for_profile(prof, hwnd=hwnd)
|
||||||
steps: list[WakeStepResult] = []
|
steps: list[WakeStepResult] = []
|
||||||
|
|
||||||
@@ -163,7 +212,11 @@ def wake(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
detail, status = fn(h, prof)
|
# native_helper:可重定向到同 PID 主窗注入;其余策略仍作用在 probe 窗
|
||||||
|
target = resolve_native_hwnd(h, prof) if name == "native_helper" else h
|
||||||
|
detail, status = fn(target, prof)
|
||||||
|
if name == "native_helper" and int(target) != int(h):
|
||||||
|
detail = f"inject_hwnd={target} probe_hwnd={h} | {detail}"
|
||||||
steps.append(
|
steps.append(
|
||||||
WakeStepResult(
|
WakeStepResult(
|
||||||
strategy=name,
|
strategy=name,
|
||||||
@@ -184,6 +237,7 @@ def wake(
|
|||||||
time.sleep(pause_s)
|
time.sleep(pause_s)
|
||||||
if stop_when_warm:
|
if stop_when_warm:
|
||||||
try:
|
try:
|
||||||
|
# 始终对 profile 目标窗(浮层)判暖,不是 inject 窗
|
||||||
pr = probe(prof, hwnd=h)
|
pr = probe(prof, hwnd=h)
|
||||||
if pr.warm:
|
if pr.warm:
|
||||||
steps.append(
|
steps.append(
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ def window_area(hwnd: int) -> int:
|
|||||||
return max(0, rect.right - rect.left) * max(0, rect.bottom - rect.top)
|
return max(0, rect.right - rect.left) * max(0, rect.bottom - rect.top)
|
||||||
|
|
||||||
|
|
||||||
|
def get_window_pid(hwnd: int) -> int:
|
||||||
|
require_windows()
|
||||||
|
pid = wintypes.DWORD()
|
||||||
|
_user32().GetWindowThreadProcessId(int(hwnd), ctypes.byref(pid))
|
||||||
|
return int(pid.value)
|
||||||
|
|
||||||
|
|
||||||
|
def find_same_pid_hwnd_by_class(
|
||||||
|
*,
|
||||||
|
pid: int,
|
||||||
|
class_name: str,
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""同进程下指定 class 的可见顶层窗(多窗取面积最大)。"""
|
||||||
|
hwnds = enum_hwnds(class_name=class_name)
|
||||||
|
same = [h for h in hwnds if get_window_pid(h) == int(pid)]
|
||||||
|
if not same:
|
||||||
|
return None
|
||||||
|
return pick_largest(same) if len(same) > 1 else same[0]
|
||||||
|
|
||||||
|
|
||||||
def enum_hwnds(
|
def enum_hwnds(
|
||||||
*,
|
*,
|
||||||
class_name: Optional[str] = None,
|
class_name: Optional[str] = None,
|
||||||
|
|||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from . import anti_detect, artifacts, errors, human_login, stealth
|
from . import anti_detect, artifacts, errors, human_login, stealth, wait
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .browser import launch_persistent_browser
|
from .browser import launch_persistent_browser
|
||||||
@@ -14,14 +14,20 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
RpaVideoSession = None # type: ignore[misc, assignment]
|
RpaVideoSession = None # type: ignore[misc, assignment]
|
||||||
|
|
||||||
|
from .wait import PollUntilResult, poll_until, poll_until_sync
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"anti_detect",
|
"anti_detect",
|
||||||
"artifacts",
|
"artifacts",
|
||||||
"errors",
|
"errors",
|
||||||
"human_login",
|
"human_login",
|
||||||
"stealth",
|
"stealth",
|
||||||
|
"wait",
|
||||||
"launch_persistent_browser",
|
"launch_persistent_browser",
|
||||||
"RpaVideoSession",
|
"RpaVideoSession",
|
||||||
|
"PollUntilResult",
|
||||||
|
"poll_until",
|
||||||
|
"poll_until_sync",
|
||||||
# re-export common anti_detect helpers
|
# re-export common anti_detect helpers
|
||||||
"random_delay",
|
"random_delay",
|
||||||
"human_delay_short",
|
"human_delay_short",
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .. import config
|
from .. import config
|
||||||
from ..runtime_env import find_chrome_executable
|
from ..runtime_env import find_chrome_executable
|
||||||
|
from .errors import ERR_BROWSER_START_TIMEOUT, ERR_MISSING_BROWSER
|
||||||
from .stealth import (
|
from .stealth import (
|
||||||
STEALTH_INIT_SCRIPT,
|
STEALTH_INIT_SCRIPT,
|
||||||
persistent_context_launch_parts,
|
persistent_context_launch_parts,
|
||||||
@@ -15,6 +17,23 @@ from .stealth import (
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from playwright.async_api import BrowserContext
|
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(
|
async def launch_persistent_browser(
|
||||||
playwright,
|
playwright,
|
||||||
@@ -24,11 +43,17 @@ async def launch_persistent_browser(
|
|||||||
executable_path: str | None = None,
|
executable_path: str | None = None,
|
||||||
headless: bool | None = None,
|
headless: bool | None = None,
|
||||||
extra_args: list[str] | None = None,
|
extra_args: list[str] | None = None,
|
||||||
|
timeout_ms: int | None = None,
|
||||||
) -> "BrowserContext":
|
) -> "BrowserContext":
|
||||||
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
"""用 stealth 参数启动 persistent context + 注入 STEALTH_INIT_SCRIPT。
|
||||||
|
|
||||||
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
headless=None 时读 OPENCLAW_BROWSER_HEADLESS。
|
||||||
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
录屏由 RpaVideoSession(ffmpeg)负责,本函数不向 Playwright 传递任何录屏参数。
|
||||||
|
|
||||||
|
timeout_ms:
|
||||||
|
可选启动超时(毫秒)。为 None 时读 OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS;
|
||||||
|
仍为空则不包裹超时(与历史行为一致)。超时抛 RuntimeError,前缀
|
||||||
|
ERROR:BROWSER_START_TIMEOUT。本函数不做自动重试。
|
||||||
"""
|
"""
|
||||||
if headless is None:
|
if headless is None:
|
||||||
headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
|
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()
|
chrome = executable_path or find_chrome_executable()
|
||||||
if not chrome:
|
if not chrome:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
f"{ERR_MISSING_BROWSER} 未检测到 Chrome 或 Edge 浏览器,请安装后重试。"
|
||||||
)
|
)
|
||||||
|
|
||||||
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
|
args, ignore = persistent_context_launch_parts(extra_args=extra_args)
|
||||||
@@ -51,7 +76,22 @@ async def launch_persistent_browser(
|
|||||||
if ignore is not None:
|
if ignore is not None:
|
||||||
launch_kwargs["ignore_default_args"] = ignore
|
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():
|
if stealth_enabled():
|
||||||
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
await context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ ERR_MISSING_BROWSER = "ERROR:MISSING_BROWSER"
|
|||||||
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
|
ERR_DEVICE_NOT_READY = "ERROR:DEVICE_NOT_READY"
|
||||||
ERR_WINDOW_NOT_FOUND = "ERROR:WINDOW_NOT_FOUND"
|
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__ = [
|
__all__ = [
|
||||||
"ERR_REQUIRE_LOGIN",
|
"ERR_REQUIRE_LOGIN",
|
||||||
"ERR_LOGIN_TIMEOUT",
|
"ERR_LOGIN_TIMEOUT",
|
||||||
@@ -16,4 +21,7 @@ __all__ = [
|
|||||||
"ERR_MISSING_BROWSER",
|
"ERR_MISSING_BROWSER",
|
||||||
"ERR_DEVICE_NOT_READY",
|
"ERR_DEVICE_NOT_READY",
|
||||||
"ERR_WINDOW_NOT_FOUND",
|
"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
|
import jiangchang_skill_core
|
||||||
from . import config
|
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 .media_assets import probe_background_music, probe_media_assets, resolve_media_assets_root
|
||||||
from .runtime_env import platform_default_data_root
|
from .runtime_env import platform_default_data_root
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ class RuntimeDiagnostics:
|
|||||||
|
|
||||||
record_video_enabled: bool
|
record_video_enabled: bool
|
||||||
issues: tuple[RuntimeIssue, ...]
|
issues: tuple[RuntimeIssue, ...]
|
||||||
|
# 只读机器信号(供电/限频);探不到为空 dict / probed=False,永不单独导致 fatal
|
||||||
|
machine_power: dict[str, Any]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_fatal_issues(self) -> bool:
|
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(
|
return RuntimeDiagnostics(
|
||||||
skill_slug=skill_slug,
|
skill_slug=skill_slug,
|
||||||
python_executable=sys.executable,
|
python_executable=sys.executable,
|
||||||
@@ -276,6 +285,7 @@ def collect_runtime_diagnostics(
|
|||||||
background_music_sample_path=str(music_sample) if music_sample else None,
|
background_music_sample_path=str(music_sample) if music_sample else None,
|
||||||
record_video_enabled=record_video_enabled,
|
record_video_enabled=record_video_enabled,
|
||||||
issues=tuple(issues),
|
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_issue: {diagnostics.background_music_issue or ''}",
|
||||||
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
|
f"background_music_sample_path: {diagnostics.background_music_sample_path or ''}",
|
||||||
f"record_video_enabled: {diagnostics.record_video_enabled}",
|
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:
|
for issue in diagnostics.issues:
|
||||||
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
|
lines.append(f"runtime_issue[{issue.severity}]: {issue.code} - {issue.message}")
|
||||||
return lines
|
return lines
|
||||||
@@ -321,6 +348,7 @@ def runtime_diagnostics_dict(diagnostics: RuntimeDiagnostics) -> dict[str, Any]:
|
|||||||
"background_music_issue": diagnostics.background_music_issue,
|
"background_music_issue": diagnostics.background_music_issue,
|
||||||
"background_music_sample_path": diagnostics.background_music_sample_path,
|
"background_music_sample_path": diagnostics.background_music_sample_path,
|
||||||
"record_video_enabled": diagnostics.record_video_enabled,
|
"record_video_enabled": diagnostics.record_video_enabled,
|
||||||
|
"machine_power": diagnostics.machine_power,
|
||||||
"runtime_issues": [
|
"runtime_issues": [
|
||||||
{"code": issue.code, "message": issue.message, "severity": issue.severity}
|
{"code": issue.code, "message": issue.message, "severity": issue.severity}
|
||||||
for issue in diagnostics.issues
|
for issue in diagnostics.issues
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class _SkillContextFilter(logging.Filter):
|
|||||||
_FORMAT = (
|
_FORMAT = (
|
||||||
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
|
"%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s"
|
||||||
)
|
)
|
||||||
_DATEFMT = "%Y-%m-%dT%H:%M:%S"
|
_DATEFMT = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
|
||||||
def _log_level_from_env() -> int:
|
def _log_level_from_env() -> int:
|
||||||
|
|||||||
@@ -34,9 +34,18 @@ class ProfileTests(unittest.TestCase):
|
|||||||
self.assertIn("search_result", p.warm_markers)
|
self.assertIn("search_result", p.warm_markers)
|
||||||
self.assertIn("user_item", p.warm_markers)
|
self.assertIn("user_item", p.warm_markers)
|
||||||
self.assertFalse(p.activate_maximize)
|
self.assertFalse(p.activate_maximize)
|
||||||
|
# 1.2.10+:对浮层本身 n2-wake(多窗子类化);禁止 Maximize;不改注主窗
|
||||||
|
self.assertFalse((p.native_hwnd_class or "").strip())
|
||||||
self.assertIn("native_helper", p.wake_strategies)
|
self.assertIn("native_helper", p.wake_strategies)
|
||||||
self.assertIn("n2-wake", p.native_args or [])
|
self.assertIn("n2-wake", p.native_args or [])
|
||||||
|
|
||||||
|
def test_resolve_native_hwnd_fallback_without_class(self):
|
||||||
|
from jiangchang_desktop_wake.wake import resolve_native_hwnd
|
||||||
|
|
||||||
|
p = Profile.from_dict({"id": "x", "window_class": "Foo"})
|
||||||
|
self.assertIsNone(p.native_hwnd_class)
|
||||||
|
self.assertEqual(resolve_native_hwnd(12345, p), 12345)
|
||||||
|
|
||||||
def test_unknown_profile(self):
|
def test_unknown_profile(self):
|
||||||
from jiangchang_desktop_wake.errors import DesktopWakeError
|
from jiangchang_desktop_wake.errors import DesktopWakeError
|
||||||
|
|
||||||
|
|||||||
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,
|
background_music_sample_path=None,
|
||||||
record_video_enabled=False,
|
record_video_enabled=False,
|
||||||
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
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.has_fatal_issues is False
|
||||||
assert diag.issue_codes() == ["ffmpeg_unavailable"]
|
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,
|
background_music_sample_path=None,
|
||||||
record_video_enabled=False,
|
record_video_enabled=False,
|
||||||
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
issues=(RuntimeIssue(code="ffmpeg_unavailable", message="no ffmpeg"),),
|
||||||
|
machine_power={"probed": False, "ac_power": None, "nvidia": None},
|
||||||
)
|
)
|
||||||
lines = format_runtime_health_lines(diag)
|
lines = format_runtime_health_lines(diag)
|
||||||
issue_lines = [line for line in lines if line.startswith("runtime_issue[")]
|
issue_lines = [line for line in lines if line.startswith("runtime_issue[")]
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ class TestUnifiedLogging(unittest.TestCase):
|
|||||||
self.assertFalse(any(isinstance(h, TimedRotatingFileHandler) for h in handlers))
|
self.assertFalse(any(isinstance(h, TimedRotatingFileHandler) for h in handlers))
|
||||||
self.assertTrue(any(isinstance(h, logging.NullHandler) for h in handlers))
|
self.assertTrue(any(isinstance(h, logging.NullHandler) for h in handlers))
|
||||||
|
|
||||||
|
def test_log_timestamp_uses_space_not_iso_t(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
log_path = os.path.join(tmp, "skill.log")
|
||||||
|
logger_name = "test.logger.datefmt"
|
||||||
|
with patch.object(ul, "get_skill_log_file_path", return_value=log_path):
|
||||||
|
ul.setup_skill_logging("receive-order", logger_name)
|
||||||
|
ul.get_skill_logger().info("hello-datefmt")
|
||||||
|
ul._clear_logger_handlers(logging.getLogger(logger_name))
|
||||||
|
with open(log_path, encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
self.assertRegex(
|
||||||
|
text,
|
||||||
|
r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|",
|
||||||
|
)
|
||||||
|
self.assertNotRegex(
|
||||||
|
text,
|
||||||
|
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} \|",
|
||||||
|
)
|
||||||
|
|
||||||
def test_attach_unified_file_handler_unwritable(self) -> None:
|
def test_attach_unified_file_handler_unwritable(self) -> None:
|
||||||
logger_name = "test.logger.attach.unwritable"
|
logger_name = "test.logger.attach.unwritable"
|
||||||
with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")):
|
with patch.object(ul, "_can_open_log_file", return_value=(False, "permission denied")):
|
||||||
|
|||||||
Reference in New Issue
Block a user