From 2174b2b57398170a7e50203646eb60b0db339ce7 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Mon, 20 Apr 2026 13:40:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E5=8C=A0=E5=8E=82=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/jiangchang-desktop-sdk/pyproject.toml | 13 + .../src/jiangchang_desktop_sdk/__init__.py | 15 + .../src/jiangchang_desktop_sdk/client.py | 588 ++++++++++++++++++ .../src/jiangchang_desktop_sdk/exceptions.py | 23 + .../src/jiangchang_desktop_sdk/types.py | 38 ++ .../tests/test_client.py | 36 ++ 6 files changed, 713 insertions(+) create mode 100644 sdk/jiangchang-desktop-sdk/pyproject.toml create mode 100644 sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/__init__.py create mode 100644 sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/client.py create mode 100644 sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/exceptions.py create mode 100644 sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/types.py create mode 100644 sdk/jiangchang-desktop-sdk/tests/test_client.py diff --git a/sdk/jiangchang-desktop-sdk/pyproject.toml b/sdk/jiangchang-desktop-sdk/pyproject.toml new file mode 100644 index 0000000..52ee397 --- /dev/null +++ b/sdk/jiangchang-desktop-sdk/pyproject.toml @@ -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"] diff --git a/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/__init__.py b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/__init__.py new file mode 100644 index 0000000..ed58fb8 --- /dev/null +++ b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/__init__.py @@ -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" diff --git a/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/client.py b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/client.py new file mode 100644 index 0000000..990c48f --- /dev/null +++ b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/client.py @@ -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 diff --git a/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/exceptions.py b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/exceptions.py new file mode 100644 index 0000000..1d1bfa9 --- /dev/null +++ b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/exceptions.py @@ -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 diff --git a/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/types.py b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/types.py new file mode 100644 index 0000000..6e902c1 --- /dev/null +++ b/sdk/jiangchang-desktop-sdk/src/jiangchang_desktop_sdk/types.py @@ -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 diff --git a/sdk/jiangchang-desktop-sdk/tests/test_client.py b/sdk/jiangchang-desktop-sdk/tests/test_client.py new file mode 100644 index 0000000..534b3ef --- /dev/null +++ b/sdk/jiangchang-desktop-sdk/tests/test_client.py @@ -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)