This commit is contained in:
38
src/jiangchang_desktop_sdk/__init__.py
Normal file
38
src/jiangchang_desktop_sdk/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from .exceptions import (
|
||||
AppNotFoundError,
|
||||
AssertError,
|
||||
ConnectionError,
|
||||
GatewayDownError,
|
||||
JiangchangDesktopError,
|
||||
LaunchError,
|
||||
TimeoutError,
|
||||
)
|
||||
from .types import AskOptions, AssertOptions, JiangchangMessage, LaunchOptions
|
||||
|
||||
__all__ = [
|
||||
"JiangchangDesktopClient",
|
||||
"JiangchangMessage",
|
||||
"AskOptions",
|
||||
"LaunchOptions",
|
||||
"AssertOptions",
|
||||
"JiangchangDesktopError",
|
||||
"AppNotFoundError",
|
||||
"ConnectionError",
|
||||
"TimeoutError",
|
||||
"AssertError",
|
||||
"LaunchError",
|
||||
"GatewayDownError",
|
||||
]
|
||||
__version__ = "0.1.0"
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "JiangchangDesktopClient":
|
||||
from .client import JiangchangDesktopClient
|
||||
|
||||
return JiangchangDesktopClient
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return sorted({*__all__, "__version__"})
|
||||
1021
src/jiangchang_desktop_sdk/client.py
Normal file
1021
src/jiangchang_desktop_sdk/client.py
Normal file
File diff suppressed because it is too large
Load Diff
27
src/jiangchang_desktop_sdk/exceptions.py
Normal file
27
src/jiangchang_desktop_sdk/exceptions.py
Normal file
@@ -0,0 +1,27 @@
|
||||
class JiangchangDesktopError(Exception):
|
||||
"""基类"""
|
||||
pass
|
||||
|
||||
class AppNotFoundError(JiangchangDesktopError):
|
||||
"""Electron 可执行文件找不到时抛出"""
|
||||
pass
|
||||
|
||||
class ConnectionError(JiangchangDesktopError, ConnectionError):
|
||||
"""连接桌面应用失败时抛出"""
|
||||
pass
|
||||
|
||||
class TimeoutError(JiangchangDesktopError, TimeoutError):
|
||||
"""操作超时"""
|
||||
pass
|
||||
|
||||
class AssertError(JiangchangDesktopError):
|
||||
"""断言失败时抛出,消息要包含期望值和实际值"""
|
||||
pass
|
||||
|
||||
class LaunchError(JiangchangDesktopError):
|
||||
"""应用启动失败时抛出"""
|
||||
pass
|
||||
|
||||
class GatewayDownError(JiangchangDesktopError):
|
||||
"""Gateway 在等待过程中被检测到已停止/退出时抛出,便于测试立刻失败而不是空等超时。"""
|
||||
pass
|
||||
24
src/jiangchang_desktop_sdk/testing/__init__.py
Normal file
24
src/jiangchang_desktop_sdk/testing/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""匠厂桌面 E2E 与集成测试常用工具子包。
|
||||
|
||||
提供:
|
||||
- ``SkillInfo`` / ``discover_skill_root`` / ``parse_skill_md``
|
||||
- ``skill_healthcheck``
|
||||
- ``HostAPIClient`` / ``HostAPIError``
|
||||
|
||||
**不提供任何会话清理能力**:测试完全模拟真实用户的 UI 操作,
|
||||
真实用户从不会去后台删会话,测试也不该走底层 API / 文件系统去清。
|
||||
"""
|
||||
from .config import SkillInfo, discover_skill_root, parse_skill_md
|
||||
from .healthcheck import HealthCheckError, skill_healthcheck
|
||||
from .host_api import HostAPIClient, HostAPIError
|
||||
|
||||
__all__ = [
|
||||
"SkillInfo",
|
||||
"discover_skill_root",
|
||||
"parse_skill_md",
|
||||
"HealthCheckError",
|
||||
"skill_healthcheck",
|
||||
"HostAPIClient",
|
||||
"HostAPIError",
|
||||
]
|
||||
137
src/jiangchang_desktop_sdk/testing/config.py
Normal file
137
src/jiangchang_desktop_sdk/testing/config.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Skill 元信息读取。
|
||||
|
||||
负责:
|
||||
- 定位 skill 根目录(含 SKILL.md);
|
||||
- 解析 SKILL.md 的 YAML frontmatter,抽出 slug / version / name 等关键字段。
|
||||
|
||||
为避免硬性依赖 PyYAML,这里用一个**足够用**的轻量正则 parser:只抽取
|
||||
我们真正关心的字段(顶层 `name` / `version` / `author`,嵌套 `openclaw.slug`
|
||||
与 `openclaw.category`)。更复杂的 YAML 结构请自行用 PyYAML 解析。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
SKILL_MD = "SKILL.md"
|
||||
SKILL_ROOT_ENV = "JIANGCHANG_E2E_SKILL_ROOT"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
root: str
|
||||
slug: str
|
||||
name: str
|
||||
version: str
|
||||
author: str = ""
|
||||
category: str = ""
|
||||
|
||||
|
||||
def discover_skill_root(start: Optional[str] = None) -> str:
|
||||
"""定位 skill 根目录。
|
||||
|
||||
优先级:
|
||||
1. 环境变量 ``JIANGCHANG_E2E_SKILL_ROOT``(由调用方或测试入口提前注入);
|
||||
2. 从 ``start``(默认 cwd)向上回溯,直到找到含 ``SKILL.md`` 的目录。
|
||||
"""
|
||||
env = (os.environ.get(SKILL_ROOT_ENV) or "").strip()
|
||||
if env:
|
||||
env_abs = os.path.abspath(env)
|
||||
if os.path.isfile(os.path.join(env_abs, SKILL_MD)):
|
||||
return env_abs
|
||||
cur = Path(start or os.getcwd()).resolve()
|
||||
for parent in [cur, *cur.parents]:
|
||||
if (parent / SKILL_MD).exists():
|
||||
return str(parent)
|
||||
raise FileNotFoundError(
|
||||
f"未能在 {start or os.getcwd()} 及其父目录中找到 {SKILL_MD}。"
|
||||
f"请设置环境变量 {SKILL_ROOT_ENV}=<skill 根目录绝对路径>。"
|
||||
)
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||
|
||||
|
||||
def _extract_frontmatter(text: str) -> str:
|
||||
match = _FRONTMATTER_RE.match(text)
|
||||
if not match:
|
||||
raise ValueError("SKILL.md 缺少 YAML frontmatter(--- ... ---)。")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _strip_quotes(value: str) -> str:
|
||||
v = value.strip()
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
|
||||
return v[1:-1]
|
||||
return v
|
||||
|
||||
|
||||
def parse_skill_md(skill_root: str) -> SkillInfo:
|
||||
"""解析 SKILL.md 的 frontmatter,返回 SkillInfo。"""
|
||||
md_path = Path(skill_root) / SKILL_MD
|
||||
if not md_path.exists():
|
||||
raise FileNotFoundError(f"SKILL.md 不存在:{md_path}")
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
fm = _extract_frontmatter(text)
|
||||
|
||||
top: dict[str, str] = {}
|
||||
openclaw: dict[str, str] = {}
|
||||
|
||||
lines = fm.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
# 顶层 key: value
|
||||
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
|
||||
if m:
|
||||
key, rest = m.group(1), m.group(2)
|
||||
if rest.strip() == "":
|
||||
# 嵌套块,收集 2 空格缩进的子项
|
||||
i += 1
|
||||
sub: dict[str, str] = {}
|
||||
while i < len(lines):
|
||||
sub_line = lines[i]
|
||||
if sub_line.strip() == "" or sub_line.lstrip().startswith("#"):
|
||||
i += 1
|
||||
continue
|
||||
if not sub_line.startswith(" "):
|
||||
break
|
||||
sm = re.match(r"^\s+([A-Za-z_][\w-]*):\s*(.*)$", sub_line)
|
||||
if sm:
|
||||
sub[sm.group(1)] = _strip_quotes(sm.group(2))
|
||||
i += 1
|
||||
if key == "metadata":
|
||||
# openclaw 在 metadata 下再下一层:
|
||||
# metadata:
|
||||
# openclaw:
|
||||
# slug: ...
|
||||
# 上面 sub 已经拿到的只是 "openclaw" 这个 key。为稳妥起见,回头
|
||||
# 用一个更宽松的扫描:直接找所有含 `slug:` `category:` 的行。
|
||||
pass
|
||||
continue
|
||||
top[key] = _strip_quotes(rest)
|
||||
i += 1
|
||||
|
||||
# openclaw.slug / category 用宽松扫描兜底(无论嵌套几层)
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("slug:") and "slug" not in openclaw:
|
||||
openclaw["slug"] = _strip_quotes(stripped.split(":", 1)[1])
|
||||
elif stripped.startswith("category:") and "category" not in openclaw:
|
||||
openclaw["category"] = _strip_quotes(stripped.split(":", 1)[1])
|
||||
|
||||
return SkillInfo(
|
||||
root=os.path.abspath(skill_root),
|
||||
slug=openclaw.get("slug", "") or Path(skill_root).name,
|
||||
name=top.get("name", Path(skill_root).name),
|
||||
version=top.get("version", "0.0.0"),
|
||||
author=top.get("author", ""),
|
||||
category=openclaw.get("category", ""),
|
||||
)
|
||||
69
src/jiangchang_desktop_sdk/testing/healthcheck.py
Normal file
69
src/jiangchang_desktop_sdk/testing/healthcheck.py
Normal file
@@ -0,0 +1,69 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""技能健康检查。
|
||||
|
||||
检查项:
|
||||
1. SKILL.md 成功解析,含 slug / version。
|
||||
2. ``python scripts/main.py health`` 命令退出码为 0(每个 skill 都约定实现)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .config import SkillInfo
|
||||
|
||||
logger = logging.getLogger("jiangchang_desktop_sdk.testing.healthcheck")
|
||||
|
||||
|
||||
class HealthCheckError(RuntimeError):
|
||||
"""健康检查失败。"""
|
||||
|
||||
|
||||
def skill_healthcheck(
|
||||
skill_info: SkillInfo,
|
||||
*,
|
||||
run_cli: bool = True,
|
||||
timeout_s: float = 15.0,
|
||||
) -> None:
|
||||
"""对一个 skill 跑本地健康检查。失败抛 ``HealthCheckError``。"""
|
||||
if not skill_info.slug:
|
||||
raise HealthCheckError(f"SKILL.md 未定义 openclaw.slug:{skill_info.root}")
|
||||
if not skill_info.version or skill_info.version == "0.0.0":
|
||||
raise HealthCheckError(f"SKILL.md 未定义 version:{skill_info.root}")
|
||||
|
||||
if not run_cli:
|
||||
return
|
||||
|
||||
main_py = os.path.join(skill_info.root, "scripts", "main.py")
|
||||
if not os.path.isfile(main_py):
|
||||
logger.warning("skill_healthcheck: %s 不存在,跳过 CLI health 检查", main_py)
|
||||
return
|
||||
|
||||
cmd = [sys.executable, main_py, "health"]
|
||||
logger.debug("skill_healthcheck: 运行 %s (cwd=%s)", cmd, skill_info.root)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_s,
|
||||
cwd=skill_info.root,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HealthCheckError(
|
||||
f"skill_healthcheck: '{' '.join(cmd)}' 超时 {timeout_s}s"
|
||||
) from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise HealthCheckError(
|
||||
f"skill_healthcheck: '{' '.join(cmd)}' 退出码 {proc.returncode}\n"
|
||||
f"stdout: {proc.stdout[:600]}\n"
|
||||
f"stderr: {proc.stderr[:600]}"
|
||||
)
|
||||
logger.info(
|
||||
"skill_healthcheck: %s v%s OK", skill_info.slug, skill_info.version
|
||||
)
|
||||
82
src/jiangchang_desktop_sdk/testing/host_api.py
Normal file
82
src/jiangchang_desktop_sdk/testing/host_api.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""匠厂 Host HTTP API 的 Python 轻封装。
|
||||
|
||||
使用 stdlib urllib,避免为 SDK 引入新运行时依赖。当前只暴露本模块
|
||||
真正需要的端点(健康探活、agent 列表),**不包含任何会话删除/会话
|
||||
文件系统操作**——测试侧要保持"只通过 UI 操作,不走底层接口"的原则,
|
||||
后续如需访问其他 host-api 端点请在此处按需添加读型方法。
|
||||
|
||||
Host API 默认端口:`13210`(见 jiangchang/electron/utils/config.ts 中 `CLAWX_HOST_API`)。
|
||||
可通过环境变量 ``CLAWX_PORT_CLAWX_HOST_API`` 或本类的 ``port`` 参数覆盖。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
DEFAULT_PORT = 13210
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
|
||||
logger = logging.getLogger("jiangchang_desktop_sdk.testing.host_api")
|
||||
|
||||
|
||||
class HostAPIError(RuntimeError):
|
||||
"""Host API 调用失败(网络错误或非 2xx 响应)。"""
|
||||
|
||||
|
||||
class HostAPIClient:
|
||||
def __init__(
|
||||
self,
|
||||
host: str = DEFAULT_HOST,
|
||||
port: Optional[int] = None,
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
self.host = host
|
||||
env_port = os.environ.get("CLAWX_PORT_CLAWX_HOST_API")
|
||||
self.port = port or (int(env_port) if env_port else DEFAULT_PORT)
|
||||
self.timeout = timeout
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
def _request(self, method: str, path: str, body: Any = None) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
data = None
|
||||
headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return raw
|
||||
except urllib.error.HTTPError as exc:
|
||||
payload: Any
|
||||
try:
|
||||
payload = json.loads(exc.read().decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
payload = str(exc)
|
||||
raise HostAPIError(f"{method} {path} -> HTTP {exc.code}: {payload}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise HostAPIError(f"{method} {path} -> {exc}") from exc
|
||||
|
||||
def ping(self) -> bool:
|
||||
try:
|
||||
self.list_agents()
|
||||
return True
|
||||
except HostAPIError:
|
||||
return False
|
||||
|
||||
def list_agents(self) -> Dict[str, Any]:
|
||||
return self._request("GET", "/api/agents") or {}
|
||||
48
src/jiangchang_desktop_sdk/types.py
Normal file
48
src/jiangchang_desktop_sdk/types.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List
|
||||
|
||||
@dataclass
|
||||
class JiangchangMessage:
|
||||
"""单条会话消息"""
|
||||
id: str
|
||||
role: str # 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: str
|
||||
timestamp: float
|
||||
is_error: bool = False
|
||||
tool_call_id: Optional[str] = None
|
||||
tool_name: Optional[str] = None
|
||||
tool_status: Optional[str] = None # 'running' | 'completed' | 'error'
|
||||
thinking_content: Optional[str] = None
|
||||
|
||||
@dataclass
|
||||
class AskOptions:
|
||||
"""ask() 方法的选项"""
|
||||
timeout: int = 120000 # 毫秒
|
||||
wait_for_tools: bool = True
|
||||
agent_id: str = "main"
|
||||
# 拟人化输入:每个字符间延迟(毫秒)。0 = 直接 fill()。
|
||||
typing_delay_ms: int = 25
|
||||
# 发送方式:True=按 Enter 键;False=点击发送按钮
|
||||
use_enter_key: bool = True
|
||||
# 每次 ask 前是否自动新建任务(避免上下文污染)
|
||||
new_task: bool = True
|
||||
# 流式输出判稳阈值(秒):助手消息文本连续该秒数无增长视为完成
|
||||
stable_seconds: float = 3.0
|
||||
# 轮询间隔(秒)
|
||||
poll_interval: float = 0.5
|
||||
|
||||
@dataclass
|
||||
class LaunchOptions:
|
||||
"""launch_app() 方法的选项"""
|
||||
executable_path: Optional[str] = None # 默认从 JIANGCHANG_E2E_APP_PATH 环境变量读取
|
||||
cdp_port: int = 9222
|
||||
startup_timeout: int = 30000
|
||||
headless: bool = False # 是否无头模式运行
|
||||
|
||||
@dataclass
|
||||
class AssertOptions:
|
||||
"""assert_contains() 方法的选项"""
|
||||
timeout: int = 5000
|
||||
match_mode: str = "contains" # 'contains' | 'regex' | 'exact'
|
||||
message_index: int = -1 # -1 表示最后一条 assistant 消息
|
||||
include_tools: bool = False
|
||||
Reference in New Issue
Block a user