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:
261
src/jiangchang_desktop_sdk/e2e_helpers.py
Normal file
261
src/jiangchang_desktop_sdk/e2e_helpers.py
Normal 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 内仍为 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})"
|
||||
)
|
||||
Reference in New Issue
Block a user