加入匠厂桌面测试
This commit is contained in:
13
sdk/jiangchang-desktop-sdk/pyproject.toml
Normal file
13
sdk/jiangchang-desktop-sdk/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "jiangchang-desktop-sdk"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "匠厂桌面应用自动化测试 SDK"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["playwright>=1.42.0"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from .client import JiangchangDesktopClient
|
||||||
|
from .types import JiangchangMessage, AskOptions, LaunchOptions, AssertOptions
|
||||||
|
from .exceptions import AppNotFoundError, TimeoutError, AssertError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"JiangchangDesktopClient",
|
||||||
|
"JiangchangMessage",
|
||||||
|
"AskOptions",
|
||||||
|
"LaunchOptions",
|
||||||
|
"AssertOptions",
|
||||||
|
"AppNotFoundError",
|
||||||
|
"TimeoutError",
|
||||||
|
"AssertError",
|
||||||
|
]
|
||||||
|
__version__ = "0.1.0"
|
||||||
588
sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/client.py
Normal file
588
sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/client.py
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
"""
|
||||||
|
JiangchangDesktopClient — 匠厂桌面应用自动化测试 SDK
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import signal
|
||||||
|
import re
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright, Browser, Page
|
||||||
|
|
||||||
|
from .types import JiangchangMessage, AskOptions, LaunchOptions, AssertOptions
|
||||||
|
from .exceptions import AppNotFoundError, ConnectionError, TimeoutError as JcTimeoutError, AssertError, LaunchError
|
||||||
|
|
||||||
|
|
||||||
|
class JiangchangDesktopClient:
|
||||||
|
"""
|
||||||
|
主要自动化客户端。
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = JiangchangDesktopClient()
|
||||||
|
# 连接到已运行的匠厂桌面应用(通过 CDP,端口 9222)
|
||||||
|
client.connect()
|
||||||
|
|
||||||
|
# 提问并等待回复
|
||||||
|
answer = client.ask("帮我计算,上海到洛杉矶一个40HQ的海运费")
|
||||||
|
print(answer)
|
||||||
|
|
||||||
|
# 断言回复内容
|
||||||
|
client.assert_contains("USD")
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._playwright = None # playwright.sync_playwright 实例
|
||||||
|
self._browser: Optional[Browser] = None
|
||||||
|
self._page: Optional[Page] = None
|
||||||
|
self._connected = False
|
||||||
|
self._owns_browser = False # 是否由我们启动的浏览器
|
||||||
|
self._electron_process = None # subprocess.Popen 启动的 Electron 进程
|
||||||
|
|
||||||
|
def _get_default_executable_path(self) -> str:
|
||||||
|
"""从环境变量获取 Electron 可执行文件路径"""
|
||||||
|
path = os.environ.get("JIANGCHANG_E2E_APP_PATH")
|
||||||
|
if not path:
|
||||||
|
raise AppNotFoundError(
|
||||||
|
"未找到 Electron 可执行文件路径。"
|
||||||
|
"请设置环境变量 JIANGCHANG_E2E_APP_PATH,"
|
||||||
|
"或调用 launch_app() 时传入 executable_path 参数。"
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _get_default_cdp_port(self) -> int:
|
||||||
|
"""从环境变量获取 CDP 端口,默认 9222"""
|
||||||
|
port = os.environ.get("JIANGCHANG_E2E_CDP_PORT", "9222")
|
||||||
|
return int(port)
|
||||||
|
|
||||||
|
def _discover_main_page_ws_url(self, cdp_port: int) -> str:
|
||||||
|
"""
|
||||||
|
通过 CDP HTTP 端点发现匠厂主窗口的 WebSocket 调试 URL。
|
||||||
|
CDP HTTP 端点格式:http://localhost:9222/json 返回目标列表,
|
||||||
|
每个目标包含 webSocketDebuggerUrl 字段。
|
||||||
|
"""
|
||||||
|
json_url = f"http://localhost:{cdp_port}/json"
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(json_url, timeout=10) as resp:
|
||||||
|
targets = json.load(resp)
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"无法访问 CDP 端点 {json_url}:{exc}。"
|
||||||
|
"请确认匠厂应用正在运行,并使用了 --remote-debugging-port=9222 参数。"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"CDP 端点 {json_url} 没有找到任何调试目标。"
|
||||||
|
"请确认匠厂应用正在运行,并使用了 --remote-debugging-port=9222 参数。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 优先选择标题含"匠厂"、"OpenClaw"、"ClawX"的页面
|
||||||
|
for t in targets:
|
||||||
|
title = t.get("title", "")
|
||||||
|
if any(kw in title for kw in ["匠厂", "OpenClaw", "ClawX"]):
|
||||||
|
ws_url = t.get("webSocketDebuggerUrl")
|
||||||
|
if ws_url:
|
||||||
|
return ws_url
|
||||||
|
|
||||||
|
# 没有匹配就用第一个可用目标
|
||||||
|
first = targets[0]
|
||||||
|
ws_url = first.get("webSocketDebuggerUrl")
|
||||||
|
if not ws_url:
|
||||||
|
raise ConnectionError("目标页面没有 WebSocket 调试 URL")
|
||||||
|
return ws_url
|
||||||
|
|
||||||
|
def connect(self, url: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
连接到已运行的匠厂桌面应用(通过 Chrome DevTools Protocol)。
|
||||||
|
|
||||||
|
url: 可选,CDP HTTP 端点,默认 http://localhost:9222
|
||||||
|
也可通过环境变量 JIANGCHANG_E2E_CDP_PORT 设置端口
|
||||||
|
"""
|
||||||
|
if self._connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
cdp_port = self._get_default_cdp_port()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._playwright = sync_playwright().start()
|
||||||
|
except Exception as exc:
|
||||||
|
raise LaunchError(f"启动 Playwright 失败: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 发现 WebSocket URL
|
||||||
|
ws_url = self._discover_main_page_ws_url(cdp_port)
|
||||||
|
|
||||||
|
# 通过 WebSocket 连接到已有浏览器
|
||||||
|
self._browser = self._playwright.chromium.connect_over_cdp(ws_url)
|
||||||
|
|
||||||
|
# 获取页面
|
||||||
|
contexts = self._browser.contexts
|
||||||
|
if not contexts:
|
||||||
|
raise ConnectionError("CDP 连接成功,但没有找到浏览器上下文")
|
||||||
|
|
||||||
|
pages = contexts[0].pages
|
||||||
|
if not pages:
|
||||||
|
raise ConnectionError("CDP 连接成功,但没有找到页面")
|
||||||
|
|
||||||
|
self._page = pages[0]
|
||||||
|
self._connected = True
|
||||||
|
self._owns_browser = False
|
||||||
|
|
||||||
|
except ConnectionError:
|
||||||
|
self._cleanup()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self._cleanup()
|
||||||
|
raise ConnectionError(f"连接失败: {exc}") from exc
|
||||||
|
|
||||||
|
def launch_app(self, options: Optional[LaunchOptions] = None) -> None:
|
||||||
|
"""
|
||||||
|
启动匠厂桌面应用(直接运行 Electron 可执行文件)。
|
||||||
|
|
||||||
|
options: 启动选项。如果 executable_path 未指定,
|
||||||
|
从环境变量 JIANGCHANG_E2E_APP_PATH 读取。
|
||||||
|
"""
|
||||||
|
if self._connected:
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
opts = options or LaunchOptions()
|
||||||
|
executable_path = opts.executable_path or self._get_default_executable_path()
|
||||||
|
cdp_port = opts.cdp_port
|
||||||
|
startup_timeout = opts.startup_timeout
|
||||||
|
|
||||||
|
if not os.path.exists(executable_path):
|
||||||
|
raise AppNotFoundError(f"Electron 可执行文件不存在: {executable_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._playwright = sync_playwright().start()
|
||||||
|
except Exception as exc:
|
||||||
|
raise LaunchError(f"启动 Playwright 失败: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 直接启动匠厂 Electron 程序,带远程调试端口
|
||||||
|
import subprocess
|
||||||
|
self._electron_process = subprocess.Popen(
|
||||||
|
[executable_path, f"--remote-debugging-port={cdp_port}"],
|
||||||
|
detached=True,
|
||||||
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 等应用启动
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 再通过 CDP 连接上去
|
||||||
|
self.connect()
|
||||||
|
self._owns_browser = False
|
||||||
|
|
||||||
|
# 等待页面加载
|
||||||
|
self._wait_for_page_ready(startup_timeout)
|
||||||
|
|
||||||
|
except AppNotFoundError:
|
||||||
|
self._cleanup()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
self._cleanup()
|
||||||
|
raise LaunchError(f"启动应用失败: {exc}") from exc
|
||||||
|
|
||||||
|
def _wait_for_page_ready(self, timeout_ms: int) -> None:
|
||||||
|
"""等待页面有标题(说明主窗口已加载)"""
|
||||||
|
start = time.time()
|
||||||
|
deadline = start + timeout_ms / 1000
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self._page and self._page.title():
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
raise JcTimeoutError(f"页面加载超时({timeout_ms}ms)")
|
||||||
|
|
||||||
|
def _cleanup(self) -> None:
|
||||||
|
"""清理所有资源"""
|
||||||
|
# 如果是我们启动的 Electron 进程,先关闭它
|
||||||
|
if self._electron_process is not None:
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
# Windows:发送 CTRL_BREAK_EVENT 然后 terminate
|
||||||
|
self._electron_process.send_signal(signal.CTRL_BREAK_EVENT if hasattr(signal, 'CTRL_BREAK_EVENT') else None)
|
||||||
|
self._electron_process.wait(timeout=5)
|
||||||
|
self._electron_process.terminate()
|
||||||
|
self._electron_process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._electron_process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._electron_process = None
|
||||||
|
|
||||||
|
if self._page:
|
||||||
|
try:
|
||||||
|
self._page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._page = None
|
||||||
|
|
||||||
|
if self._browser:
|
||||||
|
try:
|
||||||
|
if self._owns_browser:
|
||||||
|
self._browser.close()
|
||||||
|
else:
|
||||||
|
self._browser.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._browser = None
|
||||||
|
|
||||||
|
if self._playwright:
|
||||||
|
try:
|
||||||
|
self._playwright.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._playwright = None
|
||||||
|
|
||||||
|
self._connected = False
|
||||||
|
self._electron_process = None
|
||||||
|
|
||||||
|
# ─── 辅助选择器 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_chat_input_locator(self):
|
||||||
|
"""获取聊天输入框 locator,优先用 data-jcid,回退到类名/位置选择器"""
|
||||||
|
page = self.get_page()
|
||||||
|
# 优先:data-jcid="chat-input"
|
||||||
|
locator = page.locator('[data-jcid="chat-input"]')
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
# Fallback:查找 textarea(通常在 chat 输入区)
|
||||||
|
locator = page.locator('textarea').last
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
raise ConnectionError("找不到聊天输入框(textarea),请确认 data-jcid 属性已添加")
|
||||||
|
|
||||||
|
def _get_send_button_locator(self):
|
||||||
|
"""获取发送按钮 locator"""
|
||||||
|
page = self.get_page()
|
||||||
|
locator = page.locator('[data-jcid="send-button"]')
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
# Fallback:找 type="submit" 的 Button,或 textarea 同级的发送按钮
|
||||||
|
locator = page.locator('button[type="submit"]')
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
# Fallback:找最后一个主要操作按钮(排除 icon-only 小按钮)
|
||||||
|
locator = page.locator('button:not([aria-label])').last
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
raise ConnectionError("找不到发送按钮,请确认 data-jcid 属性已添加")
|
||||||
|
|
||||||
|
def _get_message_list_locator(self):
|
||||||
|
"""获取消息列表容器 locator"""
|
||||||
|
page = self.get_page()
|
||||||
|
locator = page.locator('[data-jcid="message-list"]')
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
# Fallback:找消息容器(class 含 space-y 或 max-w 的 div)
|
||||||
|
locator = page.locator('[class*="space-y-3"]')
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
# Fallback:找 chat 主区域
|
||||||
|
locator = page.locator('main, [class*="chat"]').first
|
||||||
|
if locator.count() > 0:
|
||||||
|
return locator
|
||||||
|
raise ConnectionError("找不到消息列表容器")
|
||||||
|
|
||||||
|
# ─── 核心交互方法 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _wait_for_streaming_done(self, timeout: int = 120000) -> None:
|
||||||
|
"""
|
||||||
|
等待 AI 流式输出完成。
|
||||||
|
|
||||||
|
优先检测 window.__jc_sending__ 标志(需要 UI 注入),
|
||||||
|
回退到轮询消息列表内容变化。
|
||||||
|
"""
|
||||||
|
page = self.get_page()
|
||||||
|
|
||||||
|
# 策略 1:window.__jc_sending__ 标志(由 UI runtime 注入)
|
||||||
|
try:
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => window.__jc_sending__ === false",
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass # 标志不存在,尝试策略 2
|
||||||
|
|
||||||
|
# 策略 2:轮询消息列表内容,直到连续 3 次无变化(稳定)
|
||||||
|
last_content = ""
|
||||||
|
stable_count = 0
|
||||||
|
stable_threshold = 3
|
||||||
|
poll_interval = 2.0 # 秒
|
||||||
|
max_wait = timeout / 1000
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
while time.time() - start < max_wait:
|
||||||
|
try:
|
||||||
|
msg_list = self._get_message_list_locator()
|
||||||
|
content = msg_list.inner_text()
|
||||||
|
except Exception:
|
||||||
|
content = ""
|
||||||
|
|
||||||
|
if content == last_content:
|
||||||
|
stable_count += 1
|
||||||
|
if stable_count >= stable_threshold:
|
||||||
|
return # 连续稳定则认为完成
|
||||||
|
else:
|
||||||
|
stable_count = 0
|
||||||
|
last_content = content
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
raise JcTimeoutError(f"等待 AI 回复超时({timeout}ms)")
|
||||||
|
|
||||||
|
def wait_for_response(self, timeout: int = 120000) -> None:
|
||||||
|
"""
|
||||||
|
等待当前 AI 回复完成。
|
||||||
|
|
||||||
|
timeout: 超时毫秒数,默认 120000(2分钟)
|
||||||
|
"""
|
||||||
|
if not self._connected:
|
||||||
|
raise ConnectionError("未连接到应用")
|
||||||
|
self._wait_for_streaming_done(timeout)
|
||||||
|
|
||||||
|
def ask(self, question: str, options: Optional[AskOptions] = None) -> str:
|
||||||
|
"""
|
||||||
|
向应用提问,等待回复完成,返回 assistant 回复文本。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1. 清空并填写输入框
|
||||||
|
2. 点击发送按钮
|
||||||
|
3. 等待流式输出完成(__jc_sending__ 标志 或 轮询)
|
||||||
|
4. 返回最新一条 assistant 消息内容
|
||||||
|
"""
|
||||||
|
if not self._connected:
|
||||||
|
raise ConnectionError("未连接到应用,请先调用 connect() 或 launch_app()")
|
||||||
|
|
||||||
|
opts = options or AskOptions()
|
||||||
|
page = self.get_page()
|
||||||
|
|
||||||
|
# 1. 填写问题到输入框
|
||||||
|
input_locator = self._get_chat_input_locator()
|
||||||
|
input_locator.wait_for(state="visible", timeout=10000)
|
||||||
|
input_locator.click()
|
||||||
|
input_locator.fill(question)
|
||||||
|
|
||||||
|
# 2. 点击发送
|
||||||
|
send_btn = self._get_send_button_locator()
|
||||||
|
send_btn.wait_for(state="visible", timeout=5000)
|
||||||
|
send_btn.click()
|
||||||
|
|
||||||
|
# 3. 等待回复完成
|
||||||
|
self._wait_for_streaming_done(opts.timeout)
|
||||||
|
|
||||||
|
# 4. 读取最后一条 assistant 消息
|
||||||
|
messages = self.read()
|
||||||
|
assistant_msgs = [m for m in messages if m.role == "assistant"]
|
||||||
|
if not assistant_msgs:
|
||||||
|
return ""
|
||||||
|
return assistant_msgs[-1].content
|
||||||
|
|
||||||
|
def send_file(self, file_path: str, message: Optional[str] = None) -> None:
|
||||||
|
"""
|
||||||
|
上传附件到当前会话。
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1. 点击附件按钮(data-jcid="attach-button" 或 fallback)
|
||||||
|
2. 通过 input[type="file"] 设置文件路径
|
||||||
|
3. 如果提供了 message,填入输入框并发送
|
||||||
|
|
||||||
|
file_path: 要上传的文件绝对路径(如 D:/test.pdf)
|
||||||
|
message: 可选,上传后附带的消息文本。如果提供,上传后自动触发发送
|
||||||
|
"""
|
||||||
|
if not self._connected:
|
||||||
|
raise ConnectionError("未连接到应用")
|
||||||
|
|
||||||
|
page = self.get_page()
|
||||||
|
|
||||||
|
# 1. 找到附件按钮
|
||||||
|
attach_btn = page.locator('[data-jcid="attach-button"]')
|
||||||
|
if attach_btn.count() == 0:
|
||||||
|
# Fallback:找包含附件/paperclip 等文字的按钮
|
||||||
|
attach_btn = page.locator('button').filter(
|
||||||
|
has_text=re.compile(r"附件|上传|paperclip|attach", re.I)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 尝试找到文件 input[type="file"]
|
||||||
|
file_input = page.locator('input[type="file"]')
|
||||||
|
if file_input.count() == 0 and attach_btn.count() > 0:
|
||||||
|
# 点击附件按钮可能会触发隐藏的 file input 显示
|
||||||
|
attach_btn.first.click()
|
||||||
|
time.sleep(0.5)
|
||||||
|
file_input = page.locator('input[type="file"]')
|
||||||
|
|
||||||
|
if file_input.count() == 0:
|
||||||
|
raise AssertError(
|
||||||
|
"找不到文件上传 input[type='file']。"
|
||||||
|
"请确认附件功能已实现,或 UI 已添加 data-jcid=\"attach-button\" 属性。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 设置文件
|
||||||
|
abs_path = os.path.abspath(file_path)
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
|
raise AppNotFoundError(f"上传文件不存在: {abs_path}")
|
||||||
|
|
||||||
|
file_input.set_input_files(abs_path)
|
||||||
|
time.sleep(1) # 等待附件预览渲染
|
||||||
|
|
||||||
|
# 4. 如果提供了 message,自动发送
|
||||||
|
if message:
|
||||||
|
input_locator = self._get_chat_input_locator()
|
||||||
|
input_locator.click()
|
||||||
|
input_locator.fill(message)
|
||||||
|
send_btn = self._get_send_button_locator()
|
||||||
|
send_btn.click()
|
||||||
|
|
||||||
|
def read(self) -> List[JiangchangMessage]:
|
||||||
|
"""
|
||||||
|
从页面 DOM 提取当前可见的所有消息。
|
||||||
|
|
||||||
|
优先通过 data-jcid 属性识别,回退到 DOM 结构推断。
|
||||||
|
返回按时间顺序排列的 JiangchangMessage 列表。
|
||||||
|
"""
|
||||||
|
if not self._connected:
|
||||||
|
raise ConnectionError("未连接到应用")
|
||||||
|
|
||||||
|
page = self.get_page()
|
||||||
|
messages: List[JiangchangMessage] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg_list = self._get_message_list_locator()
|
||||||
|
msg_list.wait_for(state="visible", timeout=10000)
|
||||||
|
except Exception:
|
||||||
|
return messages # 消息列表还没出现,返回空
|
||||||
|
|
||||||
|
# 策略 1:通过 data-jcid="message-xxx" 查找每条消息
|
||||||
|
items = page.locator('[data-jcid^="message-"]')
|
||||||
|
if items.count() > 0:
|
||||||
|
for i in range(items.count()):
|
||||||
|
el = items.nth(i)
|
||||||
|
try:
|
||||||
|
role = el.get_attribute("data-jcid-role") or "assistant"
|
||||||
|
content = el.inner_text()
|
||||||
|
is_error = el.get_attribute("data-jcid-error") == "true"
|
||||||
|
tool_name = el.get_attribute("data-jcid-tool")
|
||||||
|
messages.append(JiangchangMessage(
|
||||||
|
id=el.get_attribute("data-jcid") or str(i),
|
||||||
|
role=role,
|
||||||
|
content=content,
|
||||||
|
timestamp=time.time(),
|
||||||
|
is_error=is_error,
|
||||||
|
tool_name=tool_name,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return messages
|
||||||
|
|
||||||
|
# 策略 2:按 DOM 结构推断(通过 class 特征区分 user/assistant)
|
||||||
|
# user 消息通常 class 含 "user" 或气泡颜色为蓝色
|
||||||
|
# assistant 消息通常 class 含 "assistant" 或气泡颜色为灰色
|
||||||
|
raw_items = msg_list.locator("> *").all()
|
||||||
|
for idx, el in enumerate(raw_items):
|
||||||
|
try:
|
||||||
|
classes = el.get_attribute("class") or ""
|
||||||
|
text = el.inner_text()
|
||||||
|
if not text.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 简单启发式判断角色
|
||||||
|
if "user" in classes.lower():
|
||||||
|
role = "user"
|
||||||
|
elif "assistant" in classes.lower() or "bot" in classes.lower():
|
||||||
|
role = "assistant"
|
||||||
|
elif idx % 2 == 0: # 交替:偶数=user,奇数=assistant
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
|
role = "assistant"
|
||||||
|
|
||||||
|
messages.append(JiangchangMessage(
|
||||||
|
id=f"msg-{idx}",
|
||||||
|
role=role,
|
||||||
|
content=text,
|
||||||
|
timestamp=time.time(),
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def assert_contains(self, expected: str, options: Optional[AssertOptions] = None) -> None:
|
||||||
|
"""
|
||||||
|
断言最新一条 assistant 回复中包含指定内容。
|
||||||
|
|
||||||
|
options.match_mode 支持:
|
||||||
|
- 'contains'(默认):子串包含
|
||||||
|
- 'regex':正则表达式匹配
|
||||||
|
- 'exact':完全相等
|
||||||
|
"""
|
||||||
|
if not self._connected:
|
||||||
|
raise ConnectionError("未连接到应用")
|
||||||
|
|
||||||
|
opts = options or AssertOptions()
|
||||||
|
messages = self.read()
|
||||||
|
|
||||||
|
# 找目标消息
|
||||||
|
assistant_msgs = [m for m in messages if m.role == "assistant"]
|
||||||
|
if not assistant_msgs:
|
||||||
|
raise AssertError(
|
||||||
|
f"未找到任何 assistant 消息。期望包含:{expected!r}",
|
||||||
|
expected=expected,
|
||||||
|
actual="(无 assistant 消息)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 默认取最后一条,可通过 message_index 自定义
|
||||||
|
target_idx = opts.message_index if opts.message_index != -1 else -1
|
||||||
|
target = assistant_msgs[target_idx]
|
||||||
|
actual = target.content
|
||||||
|
|
||||||
|
# 执行匹配
|
||||||
|
matched = False
|
||||||
|
if opts.match_mode == "exact":
|
||||||
|
matched = (actual.strip() == expected.strip())
|
||||||
|
elif opts.match_mode == "regex":
|
||||||
|
import re
|
||||||
|
matched = bool(re.search(expected, actual))
|
||||||
|
else: # contains(默认)
|
||||||
|
matched = expected in actual
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
raise AssertError(
|
||||||
|
f"断言失败。期望包含:{expected!r}\n实际内容:{actual[:200]!r}",
|
||||||
|
expected=expected,
|
||||||
|
actual=actual[:200],
|
||||||
|
)
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""断开连接"""
|
||||||
|
if not self._connected:
|
||||||
|
return
|
||||||
|
self._cleanup()
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""返回当前是否已连接"""
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
def get_page(self) -> Page:
|
||||||
|
"""获取 Playwright Page 对象,供高级用法"""
|
||||||
|
if not self._page:
|
||||||
|
raise ConnectionError("未连接到应用,请先调用 connect() 或 launch_app()")
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.disconnect()
|
||||||
|
return False
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
@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
|
||||||
36
sdk/jiangchang-desktop-sdk/tests/test_client.py
Normal file
36
sdk/jiangchang-desktop-sdk/tests/test_client.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import pytest
|
||||||
|
from jiangchang_desktop_sdk import JiangchangDesktopClient, JiangchangMessage, AskOptions, LaunchOptions, AssertOptions
|
||||||
|
from jiangchang_desktop_sdk.exceptions import JiangchangDesktopError, AppNotFoundError, ConnectionError, TimeoutError, AssertError
|
||||||
|
|
||||||
|
def test_client_init():
|
||||||
|
"""实例化 JiangchangDesktopClient,验证 _connected = False"""
|
||||||
|
client = JiangchangDesktopClient()
|
||||||
|
assert client._connected is False
|
||||||
|
assert client._browser is None
|
||||||
|
assert client._page is None
|
||||||
|
|
||||||
|
def test_types_dataclasses():
|
||||||
|
"""验证 JiangchangMessage, AskOptions 等 dataclass 可以正常实例化"""
|
||||||
|
msg = JiangchangMessage(id="1", role="user", content="hello", timestamp=123.456)
|
||||||
|
assert msg.id == "1"
|
||||||
|
assert msg.role == "user"
|
||||||
|
|
||||||
|
ask_opts = AskOptions(timeout=1000)
|
||||||
|
assert ask_opts.timeout == 1000
|
||||||
|
|
||||||
|
launch_opts = LaunchOptions(headless=True)
|
||||||
|
assert launch_opts.headless is True
|
||||||
|
|
||||||
|
assert_opts = AssertOptions(match_mode="exact")
|
||||||
|
assert assert_opts.match_mode == "exact"
|
||||||
|
|
||||||
|
def test_exceptions_are_exceptions():
|
||||||
|
"""验证所有异常类都是 Exception 的子类"""
|
||||||
|
assert issubclass(JiangchangDesktopError, Exception)
|
||||||
|
assert issubclass(AppNotFoundError, JiangchangDesktopError)
|
||||||
|
assert issubclass(ConnectionError, JiangchangDesktopError)
|
||||||
|
assert issubclass(ConnectionError, ConnectionError)
|
||||||
|
assert issubclass(TimeoutError, JiangchangDesktopError)
|
||||||
|
# Note: in Python 3.10+, TimeoutError is a built-in.
|
||||||
|
# Our definition: class TimeoutError(JiangchangDesktopError, TimeoutError):
|
||||||
|
assert issubclass(AssertError, JiangchangDesktopError)
|
||||||
Reference in New Issue
Block a user