feat(sdk): 新增 e2e_helpers 共享模块,沉淀 desktop E2E 通用能力

把 disburse-payroll-icbc 实战出来的 6 个 desktop E2E 通用 helper 抽到
src/jiangchang_desktop_sdk/e2e_helpers.py,所有 skill 共用:

- drop_file_into_composer: DataTransfer 拖拽附件(绕开 SDK send_file
  在 v2.0.17+ 抛 NotImplementedError 的限制)
- wait_for_attachment_ready: 轮询附件 chip 出现
- wait_for_composer_enabled: 等输入框可见且非 disabled,避免 canSend=false 静默 no-op
- send_prompt_via_composer: fill + press_sequentially + Enter 发送
- wait_for_user_message: 60s 宽容找 user 节点(绕开 SDK ask() user_idx<0
  15s 死循环 bug,对脏 session 鲁棒)
- wait_for_agent_complete: 4 信号合流等 agent 完成
  (chat-page data-sending / window.__jc_sending / 无展开 execution-graph /
  末位 assistant has-body 且非 streaming + 文本稳定)

不抽业务断言(normalize_money / assert_zero_failures),那些是各 skill 自己的事。

所有 helper 只接受 Playwright Page 入口,不依赖 SDK client 实例。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-25 11:13:47 +08:00
parent 6e3f79dde9
commit 311441dab0

View File

@@ -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 内仍为 disabledgateway 可能未就绪)"
)
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。脏 sessionnew_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 节点,有非空 bodydata-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}"
)