diff --git a/src/jiangchang_desktop_sdk/e2e_helpers.py b/src/jiangchang_desktop_sdk/e2e_helpers.py new file mode 100644 index 0000000..7e36d45 --- /dev/null +++ b/src/jiangchang_desktop_sdk/e2e_helpers.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +""" +桌面 E2E 通用 helper:所有 skill 的 desktop E2E 测试共用。 + +为什么独立出来: +- disburse-payroll-icbc 实战验证:SDK 的 ask() 在 user_idx<0 时有死循环 bug, + 且 send_file() 是 NotImplementedError。需要绕开它们自写完整测试流程 +- 这些 helper(拖拽附件、等 user 节点、多信号合流等待 agent 完成)是 + **所有 skill 都通用的**,不应该每个 skill 都内嵌一份 + +设计原则: +- 所有 helper 只接受 Playwright Page 作为入口(不依赖 SDK client 实例) +- 时间常量默认值留出宽容(user message 60s / agent complete 1200s) +- 失败一律抛 TimeoutError 或 AssertionError,由调用方决定怎么截屏/上报 + +依赖匠厂 DOM 锚点(docs/JIANGCHANG_CUSTOM_ANCHORS.md 守护): +- [data-testid="chat-composer-input"] +- [data-testid="chat-page"][data-sending] +- [data-role="user"] / [data-role="assistant"] +- [data-testid="chat-execution-graph"][data-collapsed] +- window.__jc_sending__ +""" +from __future__ import annotations + +import base64 +import re +import time +from pathlib import Path +from typing import Optional, Tuple + +from playwright.sync_api import Page + + +__all__ = [ + "drop_file_into_composer", + "wait_for_attachment_ready", + "wait_for_composer_enabled", + "send_prompt_via_composer", + "wait_for_user_message", + "wait_for_agent_complete", +] + + +def _normalize_ws(text: str) -> str: + return re.sub(r"\s+", "", text) + + +# ─── 附件上传(绕开 SDK send_file 未实现) ────────────────────────── + +def drop_file_into_composer(page: Page, file_path: Path) -> None: + """通过 DragEvent + DataTransfer 把文件拖入聊天输入框。 + + 匠厂 ChatInput 的 onDrop 挂在外层 div 上,但 React 17+ 事件代理到 root, + 所以在 textarea 上派发 bubbles:true 的 drop 事件能正常触发 stageBufferFiles + → /api/files/stage-buffer → 真实附件链路。 + """ + payload_b64 = base64.b64encode(file_path.read_bytes()).decode("ascii") + page.evaluate( + """ + ({ base64, fileName, mimeType }) => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const file = new File([bytes], fileName, { type: mimeType }); + const dt = new DataTransfer(); + dt.items.add(file); + const textarea = document.querySelector('[data-testid="chat-composer-input"]'); + if (!textarea) { + throw new Error('chat-composer-input not found'); + } + const opts = { bubbles: true, cancelable: true, dataTransfer: dt }; + textarea.dispatchEvent(new DragEvent('dragenter', opts)); + textarea.dispatchEvent(new DragEvent('dragover', opts)); + textarea.dispatchEvent(new DragEvent('drop', opts)); + } + """, + { + "base64": payload_b64, + "fileName": file_path.name, + "mimeType": _guess_mime(file_path), + }, + ) + + +def _guess_mime(file_path: Path) -> str: + suffix = file_path.suffix.lower() + return { + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", + ".csv": "text/csv", + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".txt": "text/plain", + ".json": "application/json", + }.get(suffix, "application/octet-stream") + + +def wait_for_attachment_ready(page: Page, file_name: str, timeout_s: float = 30.0) -> None: + """stage-buffer 成功后附件 chip 会展示 fileName,轮询直到可见。""" + deadline = time.time() + timeout_s + name_loc = page.get_by_text(file_name, exact=False) + while time.time() < deadline: + try: + n = name_loc.count() + for i in range(n): + if name_loc.nth(i).is_visible(): + return + except Exception: + pass + time.sleep(0.5) + raise TimeoutError( + f"附件 {file_name!r} 在 {timeout_s}s 内未变为 ready" + ) + + +# ─── 发送 prompt(绕开 SDK ask 的 15s 锁死) ──────────────────────── + +def wait_for_composer_enabled(page: Page, timeout_s: float = 15.0) -> None: + """等输入框可见且非 disabled(避免 canSend=false 静默 no-op)。""" + page.wait_for_selector( + '[data-testid="chat-composer-input"]', + state="visible", + timeout=int(timeout_s * 1000), + ) + textarea = page.locator('[data-testid="chat-composer-input"]') + deadline = time.time() + timeout_s + while time.time() < deadline: + if not textarea.evaluate("(el) => el.disabled"): + return + time.sleep(0.3) + raise TimeoutError( + f"chat-composer-input 在 {timeout_s}s 内仍为 disabled(gateway 可能未就绪)" + ) + + +def send_prompt_via_composer(page: Page, prompt: str, *, delay_ms: int = 25) -> None: + """拟人输入 + Enter 发送。不做任何等待,返回即调用结束。""" + textarea = page.locator('[data-testid="chat-composer-input"]') + textarea.click() + textarea.fill("") + textarea.press_sequentially(prompt, delay=delay_ms) + textarea.press("Enter") + + +def wait_for_user_message(page: Page, prompt: str, timeout_s: float = 60.0) -> int: + """等我们这条 user 消息进入 DOM,返回它在 [data-role] 列表里的 index。 + + 绕开 SDK ask() 只给 15s 窗口的脆弱判据:用 prompt 文本反向匹配, + 宽容到 60s。脏 session(new_task 后 gateway 载回历史)也能正确定位。 + """ + prompt_norm = _normalize_ws(prompt) + deadline = time.time() + timeout_s + items = page.locator('[data-role]') + while time.time() < deadline: + total = items.count() + for i in range(total - 1, -1, -1): + node = items.nth(i) + role = (node.get_attribute("data-role") or "").lower() + if role != "user": + continue + text = node.inner_text(timeout=2000) or "" + text_norm = _normalize_ws(text) + if prompt_norm in text_norm or text_norm in prompt_norm: + return i + time.sleep(0.5) + raise TimeoutError( + f"在 {timeout_s}s 内未找到匹配 user 消息(prompt 归一化前 80 字=" + f"{prompt_norm[:80]!r})" + ) + + +# ─── 等 agent 完成(多信号合流) ──────────────────────────────── + +def _chat_sending_false(page: Page) -> bool: + val = page.locator('[data-testid="chat-page"]').first.get_attribute("data-sending") + return val is not None and val.lower() == "false" + + +def _window_sending_not_true(page: Page) -> bool: + val = page.evaluate("() => window.__jc_sending__") + return val is not True + + +def _no_expanded_execution_graph(page: Page) -> bool: + return ( + page.locator('[data-testid="chat-execution-graph"][data-collapsed="false"]').count() + == 0 + ) + + +def _latest_assistant_after(page: Page, user_idx: int) -> Optional[Tuple[int, str, bool]]: + items = page.locator('[data-role]') + total = items.count() + for i in range(total - 1, user_idx, -1): + node = items.nth(i) + role = (node.get_attribute("data-role") or "").lower() + if role != "assistant": + continue + body = node.inner_text(timeout=2000) or "" + streaming = (node.get_attribute("data-streaming") or "false").lower() == "true" + return i, body, streaming + return None + + +def wait_for_agent_complete( + page: Page, + user_idx: int, + *, + overall_timeout_s: float = 1200.0, + stable_seconds: float = 5.0, + poll_interval: float = 1.0, +) -> str: + """多信号合流等本轮 agent 完成,返回最末 assistant 气泡文本。 + + 完成条件(全部满足并稳定 stable_seconds 秒): + 1) chat-page data-sending="false" + 2) window.__jc_sending__ 不为 True + 3) 不存在未折叠的 execution graph + 4) user_idx 之后存在 assistant 节点,有非空 body,data-streaming != "true" + 5) 上面 assistant 节点的文本长度连续 stable_seconds 秒无变化 + """ + deadline = time.time() + overall_timeout_s + last_body_len = -1 + last_change_at = time.time() + + while time.time() < deadline: + chat_ok = _chat_sending_false(page) + win_ok = _window_sending_not_true(page) + graph_ok = _no_expanded_execution_graph(page) + picked = _latest_assistant_after(page, user_idx) + + body_text = "" + streaming = True + has_body = False + if picked is not None: + _, body_text, streaming = picked + has_body = len(body_text.strip()) > 0 + + all_ready = chat_ok and win_ok and graph_ok and has_body and not streaming + + if not all_ready: + last_body_len = -1 + last_change_at = time.time() + else: + cur_len = len(body_text) + if cur_len != last_body_len: + last_body_len = cur_len + last_change_at = time.time() + elif time.time() - last_change_at >= stable_seconds: + return body_text + + time.sleep(poll_interval) + + raise TimeoutError( + f"agent 在 {overall_timeout_s}s 内未完成(user_idx={user_idx} " + f"last_body_len={last_body_len})" + )