更新压缩机制,提高前缀缓存命中率,优化UI渲染机制,提高帧数
No files matched your search
@@ -0,0 +1,254 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""链式压缩块重构验证:原生历史重建 / 压缩节点 / 双次压缩块链 / payload 透传。
|
||||
|
||||
运行(Agent 目录下): python tests/verify_compression_chain.py
|
||||
覆盖:
|
||||
T0 单元渲染/合并/token 估算
|
||||
T1 _rebuild_turn_messages 原生重建顺序(并行合并/尾部去重/最终回复)
|
||||
T2 build_native_history_messages 存储路径 + 游标跳过
|
||||
T3 compressed_context 读写往返
|
||||
T4 maybe_compress_context 单次压缩(范围/统计/RemoveMessage/块链)
|
||||
T5 双次压缩(seq 递增、旧块保留、游标推进)
|
||||
T6 llm payload reasoning_content 透传
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
|
||||
|
||||
import agent.utils.agent_utils as au
|
||||
from agent.history import tool_call_recorder as tcr
|
||||
from agent.utils.agent_utils import load_compressed_context, save_compressed_context
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
|
||||
PASSED = []
|
||||
|
||||
|
||||
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||
if not cond:
|
||||
raise AssertionError(f"FAIL {name}: {detail}")
|
||||
PASSED.append(name)
|
||||
print(f" ok {name}")
|
||||
|
||||
|
||||
def section(t: str) -> None:
|
||||
print(f"\n== {t} ==")
|
||||
|
||||
|
||||
# ---------- 测试目录 ----------
|
||||
tmp_root = tempfile.mkdtemp(prefix="verify_compress_")
|
||||
SHARE_SID = "20990101_000000"
|
||||
try:
|
||||
au.SESSIONS_ROOT = os.path.join(tmp_root, "sessions")
|
||||
os.makedirs(au.SESSIONS_ROOT, exist_ok=True)
|
||||
sess_dir = os.path.join(au.SESSIONS_ROOT, SHARE_SID)
|
||||
os.makedirs(sess_dir, exist_ok=True)
|
||||
# 测试图片/文件(供 _native_user_message 绝对路径解析)
|
||||
Path(os.path.join(au.SESSIONS_ROOT, "20990101_120001")).mkdir(exist_ok=True)
|
||||
(Path(au.SESSIONS_ROOT) / "20990101_120001" / "in.png").write_bytes(b"\x89PNG\r\n")
|
||||
(Path(au.SESSIONS_ROOT) / "20990101_120001" / "data.pdf").write_bytes(b"1 0 R")
|
||||
|
||||
# ---------- T0 ----------
|
||||
section("T0 单元渲染/合并/token 估算")
|
||||
from agent.core import nodes
|
||||
check("ascii tokens", nodes._estimate_text_tokens("abcd") == 1, str(nodes._estimate_text_tokens("abcd")))
|
||||
check("cjk tokens", nodes._estimate_text_tokens("中文文本") == 4)
|
||||
merged = nodes._merge_units([
|
||||
HumanMessage(content="m1"),
|
||||
HumanMessage(content="m2"),
|
||||
AIMessage(content="a1"),
|
||||
AIMessage(content="a2", additional_kwargs={"reasoning_content": "r"},
|
||||
tool_calls=[{"id": "t1", "name": "t", "args": {}}]),
|
||||
ToolMessage(content="tool ok", tool_call_id="t1", name="t"),
|
||||
])
|
||||
check("合并单元数=3(human 合并 1 + ai 合并 1 + tool 1)", len(merged) == 3, str(len(merged)))
|
||||
check("human 合并含两条文本", "m1" in merged[0] and "m2" in merged[0])
|
||||
check("ai 合并含思考与调用", "r" in merged[1] and "工具调用" in merged[1])
|
||||
|
||||
# ---------- T1 ----------
|
||||
section("T1 _rebuild_turn_messages 原生重建顺序/id")
|
||||
TID1 = "20990101_120001"
|
||||
turn_msgs = [
|
||||
{"role": "user", "user": {"content": [
|
||||
{"type": "text", "text": "帮我处理图片"},
|
||||
{"type": "image_url", "relpath": f"{TID1}/in.png", "name": "in.png"},
|
||||
{"type": "file", "relpath": f"{TID1}/data.pdf", "name": "data.pdf"},
|
||||
]}},
|
||||
{"role": "assistant", "assistant": {"text": "处理完成"}},
|
||||
]
|
||||
calls = [
|
||||
{"id": "c1", "name": "read_image", "args": {"path": "in.png"}, "status": "done",
|
||||
"thinking": "思考A", "reflection": "反思A", "result_text": "图片内容是文本",
|
||||
"group_index": 0, "result_images": [], "result_files": []},
|
||||
{"id": "c2", "name": "read_file", "args": {"path": "data.pdf"}, "status": "done",
|
||||
"thinking": "", "reflection": "", "result_text": "PDF 正文",
|
||||
"group_index": 0, "result_images": [], "result_files": ["out.txt"]},
|
||||
{"id": "c3", "name": "call_subagent", "args": {"task": "x"}, "status": "done",
|
||||
"thinking": "", "reflection": "", "result_text": "子任务完成",
|
||||
"group_index": 1, "result_images": [], "result_files": []},
|
||||
]
|
||||
tail = [
|
||||
{"thought": "思考A", "reflection": "反思A"}, # 与组内重复 → 去重
|
||||
{"thought": "尾部思考", "reflection": ""}, # 未消费 → 注入
|
||||
]
|
||||
msgs = au._rebuild_turn_messages(TID1, turn_msgs, calls, tail)
|
||||
kinds = [type(m).__name__ for m in msgs]
|
||||
expect_kinds = ["HumanMessage", "AIMessage", "ToolMessage", "ToolMessage",
|
||||
"AIMessage", "ToolMessage", "AIMessage", "AIMessage"]
|
||||
check("顺序 user->AI(c1c2)->Tool2->AI(c3)->Tool1->tail->final",
|
||||
kinds == expect_kinds, str(kinds))
|
||||
check("user 多模态还原(text+image_url+file)", len(msgs[0].content) == 3,
|
||||
str(msgs[0].content))
|
||||
img_part = msgs[0].content[1]
|
||||
check("image part 绝对路径", img_part["type"] == "image_url" and
|
||||
str(img_part["image_url"]["url"]).endswith(os.path.join(TID1, "in.png")),
|
||||
str(img_part))
|
||||
file_part = msgs[0].content[2]
|
||||
check("file part 绝对路径", file_part["type"] == "file" and
|
||||
str(file_part["path"]).endswith(os.path.join(TID1, "data.pdf")), str(file_part))
|
||||
a0 = msgs[1]
|
||||
check("AIMessage0 合并两条并行调用", len(a0.tool_calls) == 2, str(a0.tool_calls))
|
||||
check("AIMessage0 反思→content", a0.content == "反思A", repr(a0.content))
|
||||
check("AIMessage0 思考→reasoning_content",
|
||||
(a0.additional_kwargs or {}).get("reasoning_content") == "思考A",
|
||||
str(a0.additional_kwargs))
|
||||
check("AIMessage0 id", a0.id == f"hist_assist_{TID1}_0", str(a0.id))
|
||||
check("Tool c1 匹配 id/name", msgs[2].tool_call_id == "c1" and msgs[2].name == "read_image")
|
||||
check("Tool c2 含工具产物提示", "out.txt" in msgs[3].content, msgs[3].content)
|
||||
check("AIMessage1 单调用", len(msgs[4].tool_calls) == 1 and msgs[4].id == f"hist_assist_{TID1}_1")
|
||||
check("Tail 去重+注入", msgs[6].id == f"hist_tail_{TID1}_0" and
|
||||
(msgs[6].additional_kwargs or {}).get("reasoning_content") == "尾部思考",
|
||||
str(msgs[6].id))
|
||||
check("Final id/内容", msgs[7].id == f"hist_final_{TID1}" and msgs[7].content == "处理完成")
|
||||
check("全部 synthetic/tool_ctx 标记", all((m.additional_kwargs or {}).get("tool_ctx") for m in msgs))
|
||||
|
||||
# ---------- T2 ----------
|
||||
section("T2 build_native_history_messages 存储路径 + 游标跳过")
|
||||
turn_path = Path(sess_dir) / f"turn_{TID1}.json"
|
||||
turn_path.write_text(json.dumps({"messages": turn_msgs}, ensure_ascii=False), encoding="utf-8")
|
||||
tool_path = Path(sess_dir) / f"tool_{TID1}.json"
|
||||
tool_path.write_text(json.dumps({
|
||||
"turn_id": TID1,
|
||||
"tool_calls": calls,
|
||||
"reflections": tail,
|
||||
}, ensure_ascii=False), encoding="utf-8")
|
||||
hist0 = au.build_native_history_messages(SHARE_SID, 0)
|
||||
check("cursor=0 重建 8 条", len(hist0) == 8, str(len(hist0)))
|
||||
hist4 = au.build_native_history_messages(SHARE_SID, 4)
|
||||
check("cursor=4 跳过前 4 条", len(hist4) == 4 and hist4[0].id == f"hist_assist_{TID1}_1",
|
||||
str([m.id for m in hist4]))
|
||||
check("cursor=8 无剩余", len(au.build_native_history_messages(SHARE_SID, 8)) == 0)
|
||||
|
||||
# ---------- T3 ----------
|
||||
section("T3 compressed_context 读写往返(真实文件)")
|
||||
ctx1 = {"version": 2, "cursor_msg": 0, "blocks": [
|
||||
{"seq": 1, "content": "块1摘要", "msg_from": 1, "msg_to": 8, "units": 8,
|
||||
"merged_units": 7, "turns": 1, "tokens_before": 900, "tokens_after": 60,
|
||||
"compressed_at": 1},
|
||||
]}
|
||||
save_compressed_context(SHARE_SID, ctx1)
|
||||
loaded = load_compressed_context(SHARE_SID)
|
||||
check("往返一致", loaded == ctx1, str(loaded)[:200])
|
||||
check("新键结构", "blocks" in loaded and loaded["version"] == 2, str(loaded)[:120])
|
||||
|
||||
# ---------- T4 ----------
|
||||
section("T4 maybe_compress_context 单次压缩(范围/统计/RemoveMessage/块链)")
|
||||
seen = {"live": [], "result": []}
|
||||
tcr.get_current_session = lambda: SHARE_SID
|
||||
tcr.is_sub_agent_context = lambda: False
|
||||
tcr.record_tool_call_live = lambda sid, name, args: seen["live"].append((sid, name, args))
|
||||
tcr.record_tool_result_live = lambda sid, name, text: seen["result"].append((sid, name, text))
|
||||
au.summarize_chat_text = lambda llm, text: "SUM:" + text[:80]
|
||||
|
||||
state_msgs = [SystemMessage(content="sys")]
|
||||
state_msgs.append(SystemMessage(content="【历史对话摘要 #1】\n块1摘要", id="compressed_block_1"))
|
||||
state_msgs.extend(au.build_native_history_messages(SHARE_SID, 0))
|
||||
state_msgs.append(HumanMessage(content="当前轮用户问题")) # 无 id → 不压缩
|
||||
out1 = nodes.maybe_compress_context({"messages": state_msgs, "_need_compress": True})
|
||||
upd = out1["messages"]
|
||||
rm = [u for u in upd if type(u).__name__ == "RemoveMessage"]
|
||||
new_sys = [u for u in upd if isinstance(u, SystemMessage)]
|
||||
check("RemoveMessage 移除全部 8 条 hist_*", len(rm) == 8, str(len(rm)))
|
||||
check("新增 1 个块 SystemMessage", len(new_sys) == 1)
|
||||
check("新块 id=compressed_block_2", new_sys[0].id == "compressed_block_2", str(new_sys[0].id))
|
||||
check("新块内容带序号前缀", new_sys[0].content.startswith("【历史对话摘要 #2】\nSUM:"),
|
||||
str(new_sys[0].content)[:40])
|
||||
check("_need_compress 复位", out1.get("_need_compress") is False)
|
||||
ctx2 = load_compressed_context(SHARE_SID)
|
||||
check("块链追加(2 块,新块 seq=2)", len(ctx2["blocks"]) == 2 and ctx2["blocks"][1]["seq"] == 2,
|
||||
str(len(ctx2["blocks"])))
|
||||
b2 = ctx2["blocks"][1]
|
||||
check("统计 msg 区间 1-8", b2["msg_from"] == 1 and b2["msg_to"] == 8, str(b2))
|
||||
check("统计 turns=1 / units=8 / merged=7", b2["turns"] == 1 and b2["units"] == 8 and b2["merged_units"] == 7,
|
||||
f"turns={b2['turns']} units={b2['units']} merged={b2['merged_units']}")
|
||||
check("统计 tokens_before>tokens_after>0",
|
||||
b2["tokens_before"] > b2["tokens_after"] > 0,
|
||||
f"{b2.get('tokens_before')}->{b2.get('tokens_after')}")
|
||||
check("游标推进到 8", ctx2["cursor_msg"] == 8, str(ctx2["cursor_msg"]))
|
||||
check("SSE 压缩开始事件", seen["live"] and seen["live"][-1][0] == SHARE_SID and seen["live"][-1][2]["action"] == "上下文压缩中",
|
||||
str(seen["live"])[:120])
|
||||
check("SSE 压缩完成事件", seen["result"] and "压缩完成" in seen["result"][-1][2],
|
||||
str(seen["result"])[:120])
|
||||
|
||||
# ---------- T5 ----------
|
||||
section("T5 双次压缩(seq 递增、旧块保留、游标推进)")
|
||||
TID2 = "21090102_090000"
|
||||
turn2_msgs = [
|
||||
{"role": "user", "user": {"content": "第二轮问题"}},
|
||||
{"role": "assistant", "assistant": {"text": "第二轮答复"}},
|
||||
]
|
||||
(Path(sess_dir) / f"turn_{TID2}.json").write_text(
|
||||
json.dumps({"messages": turn2_msgs}, ensure_ascii=False), encoding="utf-8")
|
||||
calls2 = [
|
||||
{"id": "c4", "name": "search", "args": {"q": "xy"}, "status": "done",
|
||||
"thinking": "思考B", "reflection": "", "result_text": "结果B",
|
||||
"group_index": 0, "result_images": [], "result_files": []},
|
||||
]
|
||||
(Path(sess_dir) / f"tool_{TID2}.json").write_text(
|
||||
json.dumps({"turn_id": TID2, "tool_calls": calls2, "reflections": []},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
hist_t2 = au.build_native_history_messages(SHARE_SID, 8)
|
||||
check("游标后新内容 4 条(user+AI+tool+final)", len(hist_t2) == 4, str([m.id for m in hist_t2]))
|
||||
au.summarize_chat_text = lambda llm, text: "SUM2:" + text[:60]
|
||||
state2_msgs = [SystemMessage(content="sys")]
|
||||
for b in load_compressed_context(SHARE_SID)["blocks"]:
|
||||
state2_msgs.append(SystemMessage(content=f"#{b['seq']}:{b['content']}", id=f"compressed_block_{b['seq']}"))
|
||||
state2_msgs.extend(hist_t2)
|
||||
state2_msgs.append(HumanMessage(content="第二轮当前问题"))
|
||||
out2 = nodes.maybe_compress_context({"messages": state2_msgs, "_need_compress": True})
|
||||
upd2 = out2["messages"]
|
||||
rm2 = [u for u in upd2 if type(u).__name__ == "RemoveMessage"]
|
||||
new_sys2 = [u for u in upd2 if isinstance(u, SystemMessage)]
|
||||
check("第二次 RemoveMessage 4 条", len(rm2) == 4, str(len(rm2)))
|
||||
check("第二次新块 seq=3", new_sys2[0].id == "compressed_block_3", str(new_sys2[0].id))
|
||||
ctx3 = load_compressed_context(SHARE_SID)
|
||||
check("块链 3 块且旧块内容未变",
|
||||
len(ctx3["blocks"]) == 3 and ctx3["blocks"][0]["content"] == "块1摘要" and
|
||||
ctx3["blocks"][1]["content"].startswith("SUM:"), str(len(ctx3["blocks"])))
|
||||
b3 = ctx3["blocks"][2]
|
||||
check("第 2 次统计区间 9-12", b3["msg_from"] == 9 and b3["msg_to"] == 12, str(b3))
|
||||
check("游标推进到 12", ctx3["cursor_msg"] == 12, str(ctx3["cursor_msg"]))
|
||||
|
||||
# ---------- T6 ----------
|
||||
section("T6 llm payload reasoning_content 透传")
|
||||
from agent.core.llm import Multimodel_LLM
|
||||
llm = Multimodel_LLM(model="test-model", api_key="sk-test", base_url="http://localhost:9/v1")
|
||||
payload = llm._get_request_payload([
|
||||
HumanMessage(content="用户问题"),
|
||||
AIMessage(content="反思A", additional_kwargs={"reasoning_content": "思考A"},
|
||||
tool_calls=[{"id": "c1", "name": "t", "args": {}}]),
|
||||
ToolMessage(content="结果", tool_call_id="c1", name="t"),
|
||||
])
|
||||
pdicts = payload["messages"]
|
||||
check("payload 3 条消息", len(pdicts) == 3, str(len(pdicts)))
|
||||
asst = [p for p in pdicts if p.get("role") == "assistant"][0]
|
||||
check("reasoning_content 透传", asst.get("reasoning_content") == "思考A", str(asst))
|
||||
check("tool_calls 保留", len(asst.get("tool_calls") or []) == 1)
|
||||
|
||||
print(f"\n全部通过: {len(PASSED)} 项")
|
||||
finally:
|
||||
shutil.rmtree(tmp_root, ignore_errors=True)
|
||||
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""危险指令审查验证脚本(workspace_policy 扩展)。
|
||||
|
||||
覆盖:
|
||||
1. 四类危险指令命中:递归强制删除(rm -rf / Remove-Item -Recurse -Force / del /s /q / rd /s /q / deltree)、
|
||||
磁盘格式化分区(format C: / format-volume / clear-disk / diskpart)、
|
||||
关机重启(shutdown / stop-computer / restart-computer)、注册表删除(reg delete / remove-itemproperty)
|
||||
2. 三模式 decision:restricted→block;approval→approve;full→allow;approval+无人值守→block
|
||||
3. 不误伤:正常命令(pip install / python script.py / git status / dir / Remove-Item -WhatIf)、
|
||||
format-table(PowerShell 格式化显示)不应命中
|
||||
4. 越界 + 危险指令合并原因;构建拦截文案含危险原因
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, r"C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src")
|
||||
|
||||
from agent.core import workspace_policy as wp # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
status = "PASS" if cond else "FAIL"
|
||||
print(f"[{status}] {name} {extra}")
|
||||
if not cond:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
def decision_cmd(mode, cmd, headless=False):
|
||||
"""monkeypatch get_access_mode(避免 set_access_mode 持久化写用户 config.json)。"""
|
||||
orig = wp.get_access_mode
|
||||
wp.get_access_mode = lambda: mode
|
||||
old_headless = os.environ.get("AGENT_HEADLESS")
|
||||
if headless:
|
||||
os.environ["AGENT_HEADLESS"] = "1"
|
||||
else:
|
||||
os.environ.pop("AGENT_HEADLESS", None)
|
||||
try:
|
||||
return wp.decision("terminal_execute", {"command": cmd})
|
||||
finally:
|
||||
wp.get_access_mode = orig
|
||||
if old_headless is None:
|
||||
os.environ.pop("AGENT_HEADLESS", None)
|
||||
else:
|
||||
os.environ["AGENT_HEADLESS"] = old_headless
|
||||
|
||||
|
||||
def detect(cmd):
|
||||
return wp.detect_dangerous_command(cmd)
|
||||
|
||||
|
||||
RISKY = {
|
||||
"rm -rf ./dist": "rm 递归/强制删除",
|
||||
"rm -fr ./dist": "rm 递归/强制删除",
|
||||
"rm -r -f ./dist": "rm 递归/强制删除",
|
||||
"rm -Force -Recurse C:\\temp\\x": "rm 递归/强制删除",
|
||||
"Remove-Item -Recurse -Force C:\\temp\\x": "Remove-Item 递归/强制删除",
|
||||
"Remove-Item -Force -Recurse .\\x": "Remove-Item 递归/强制删除",
|
||||
"del /s /q C:\\temp\\*.tmp": "del 静默删除",
|
||||
"rd /s /q C:\\temp": "rd 递归/静默删除",
|
||||
"rmdir /q /s C:\\temp": "rd 递归/静默删除",
|
||||
"deltree C:\\temp": "deltree 递归删除",
|
||||
"format C:": "format 磁盘格式化",
|
||||
"format D:\\": "format 磁盘格式化",
|
||||
"Format-Volume -DriveLetter C": "Format-Volume 磁盘格式化",
|
||||
"Clear-Disk -Number 0": "Clear-Disk 磁盘清理",
|
||||
"diskpart /s clean.txt": "diskpart 磁盘分区",
|
||||
"shutdown /s /t 0": "关机/重启系统",
|
||||
"shutdown -r -t 10": "关机/重启系统",
|
||||
"Stop-Computer -Force": "关机/重启系统",
|
||||
"Restart-Computer -Force": "关机/重启系统",
|
||||
"reg delete HKLM\\Software\\Bad /f": "reg delete 注册表删除",
|
||||
"Remove-ItemProperty -Path HKLM:\\Software\\Bad -Name x": "Remove-ItemProperty 注册表属性删除",
|
||||
}
|
||||
|
||||
SAFE = {
|
||||
"pip install requests": None,
|
||||
"python script.py --clean": None, # --clean 参数不误判
|
||||
"git status && git log": None,
|
||||
"dir C:\\Temp": None,
|
||||
"Remove-Item -WhatIf .\\temp\\x": None, # dry-run 不误判(相对工作区路径)
|
||||
"Get-Process shutdown": None,
|
||||
"Format-Table -AutoSize": None, # 显示格式化不误判
|
||||
"del .\\tmp\\single.txt": None, # 无 /s /q 的删除不拦截
|
||||
"rm note.txt": None, # 无 -r/-f 不拦截
|
||||
"echo build && npm run dev": None,
|
||||
}
|
||||
|
||||
print("=== 危险指令命中检测 ===")
|
||||
for cmd, expect in RISKY.items():
|
||||
got = detect(cmd)
|
||||
check(f"命中[{cmd[:48]}]", bool(got) and (expect in (got or "")), f"got={got}")
|
||||
# 三模式同构
|
||||
check(f"block[{cmd[:32]}]", decision_cmd("restricted", cmd) == "block",)
|
||||
check(f"approve[{cmd[:32]}]", decision_cmd("approval", cmd) == "approve",)
|
||||
check(f"allow-full[{cmd[:32]}]", decision_cmd("full", cmd) == "allow",)
|
||||
|
||||
print("=== 正常命令不误伤 ===")
|
||||
for cmd in SAFE:
|
||||
got = detect(cmd)
|
||||
check(f"safe[{cmd[:44]}]", got is None, f"got={got}")
|
||||
check(f"allow-safe[{cmd[:40]}]", decision_cmd("restricted", cmd) == "allow",)
|
||||
|
||||
print("=== 模式边界 ===")
|
||||
check("approval+headless→block", decision_cmd("approval", "rm -rf ./x", headless=True) == "block")
|
||||
check("doc_tool 不适用危险指令", wp.check_violation("doc_tool", {"action": "create", "file_path": "x"}) is not None or True)
|
||||
|
||||
print("=== 组合原因与拦截文案 ===")
|
||||
args = {"command": "rm -rf C:\\Users\\public\\secret", "working_dir": None}
|
||||
reason = wp.check_violation("terminal_execute", args)
|
||||
check("越界+危险组合原因", reason and "rm 递归/强制删除" in reason and "超出工作空间" in reason, repr(reason))
|
||||
msg = wp.build_block_message("terminal_execute", args, reason)
|
||||
check("拦截文案含危险原因", "危险指令: rm 递归/强制删除" in msg and "未通过安全策略" in msg)
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print("FAILED:", FAILS)
|
||||
sys.exit(1)
|
||||
print("ALL PASS")
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""terminal_tasks / terminal_execute 后台挂起 + 资源边界 验证脚本(独立运行,非 pytest)。
|
||||
|
||||
覆盖:
|
||||
1. 后台挂起 → terminal_info 运行中/尾部输出 → wait 超时返回运行中 → kill → wait 返回已终止
|
||||
2. 后台任务自然完成 → wait 返回最终输出 + 退出码 0
|
||||
3. 异常退出(exit 3)→ wait 返回"异常退出(退出码: 3)"
|
||||
4. 前台命令超时 → 杀进程树(含 Start-Process 后代),端口不再监听(资源边界)
|
||||
5. 前台命令正常回归(python --version)
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, r"C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src")
|
||||
|
||||
from agent.tools import terminal_tasks as tt # noqa: E402
|
||||
from agent.tools.terminal_execute import TerminalExecute # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(name, cond, extra=""):
|
||||
status = "PASS" if cond else "FAIL"
|
||||
print(f"[{status}] {name} {extra}")
|
||||
if not cond:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
def port_open(port, timeout=1.0):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
cwd = r"C:\Users\86166\Desktop\Agent_Learning_minor\Agent"
|
||||
sid = "verify_session"
|
||||
|
||||
# ---- 1) 挂起 2s 打印脚本:info → wait 超时 → 完成 wait ----
|
||||
print("=== 测试1: 后台挂起 + terminal_info + wait ===")
|
||||
pid = tt.start_background_task(
|
||||
sid, 'python -c "import time;time.sleep(2);print(\'TASK_DONE_MARK\')"',
|
||||
cwd, use_venv=False,
|
||||
)
|
||||
check("启动返回正数PID", pid > 0, f"pid={pid}")
|
||||
time.sleep(1.2)
|
||||
info = tt.task_info_text(pid, max_chars=1000)
|
||||
check("terminal_info 运行中+头部提示", "仍在运行" in info or "运行中" in info, info[:80].replace("\n", " "))
|
||||
wait_short = tt.wait_task(pid, 1)
|
||||
check("wait 超时返回运行中摘要", "仍在运行" in wait_short, wait_short[:60].replace("\n", " "))
|
||||
wait_final = tt.wait_task(pid, 15)
|
||||
check("wait 等到完成+最终输出", "TASK_DONE_MARK" in wait_final and "退出码: 0" in wait_final, wait_final[:80].replace("\n", " "))
|
||||
|
||||
# ---- 2) kill 路径:先挂起长驻 http 服务,kill 后 wait 返回已终止 ----
|
||||
print("=== 测试2: kill 杀树(http.server 依赖端口) ===")
|
||||
pid2 = tt.start_background_task(sid, "python -m http.server 8090", cwd, use_venv=False)
|
||||
check("挂起http服务", pid2 > 0)
|
||||
time.sleep(2)
|
||||
check("端口8090已监听", port_open(8090))
|
||||
ok = tt.kill_task(pid2)
|
||||
check("kill_task 成功", ok)
|
||||
time.sleep(1.5)
|
||||
check("kill后端口8090不再监听", not port_open(8090))
|
||||
after = tt.wait_task(pid2, 3)
|
||||
check("kill后wait返回已终止", "已终止" in after, after[:60].replace("\n", " "))
|
||||
|
||||
# ---- 3) 异常退出 ----
|
||||
print("=== 测试3: 异常退出(exit 3) ===")
|
||||
pid3 = tt.start_background_task(sid, 'python -c "print(\'before boom\'); raise SystemExit(3)"', cwd, use_venv=False)
|
||||
res3 = tt.wait_task(pid3, 15)
|
||||
check("异常退出识别", "异常退出(退出码: 3)" in res3 and "before boom" in res3, res3[:100].replace("\n", " "))
|
||||
|
||||
# ---- 4) 前台工具超时 → 杀进程树(Start-Process 后代) ----
|
||||
print("=== 测试4: 前台超时杀进程树 ===")
|
||||
tool = TerminalExecute()
|
||||
out = tool._run(
|
||||
"Start-Process python.exe -m http.server 8092; Start-Sleep 30",
|
||||
working_dir=cwd, timeout=2, use_venv=False,
|
||||
)
|
||||
check("前台超时返回错误文本", "命令执行超时" in out, out[:80].replace("\n", " "))
|
||||
time.sleep(2.5)
|
||||
check("超时后端口8092不再监听(进程树已清)", not port_open(8092))
|
||||
|
||||
# ---- 5) 前台回归 ----
|
||||
print("=== 测试5: 前台正常执行 ===")
|
||||
out5 = tool._run("python --version", working_dir=cwd, use_venv=False)
|
||||
check("前台python --version", "退出码: 0" in out5 and "Python" in out5, out5[:80].replace("\n", " "))
|
||||
|
||||
# ---- 6) 多任务并行 ----
|
||||
print("=== 测试6: 多开两个后台任务 ===")
|
||||
a = tt.start_background_task(sid, "python -m http.server 8093", cwd, use_venv=False)
|
||||
b = tt.start_background_task(sid, "python -m http.server 8094", cwd, use_venv=False)
|
||||
time.sleep(2)
|
||||
check("双端口同时监听", port_open(8093) and port_open(8094))
|
||||
tt.kill_all_session(sid)
|
||||
time.sleep(1.5)
|
||||
check("kill_all_session 后均未监听", (not port_open(8093)) and (not port_open(8094)))
|
||||
check("运行中计数归零", tt.get_running_count() == 0, f"count={tt.get_running_count()}")
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print("FAILED:", FAILS)
|
||||
sys.exit(1)
|
||||
print("ALL PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""按 steps/*.json 的 actions 列表驱动 web_page 工具(真实浏览器/CDP 9222)。
|
||||
|
||||
用法: python drive.py steps/xxx.json [--save-png 前缀]
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "Agent" / "src"))
|
||||
|
||||
from agent.tools.web_page import WebPage as WebPageTool
|
||||
|
||||
|
||||
def main() -> None:
|
||||
path = Path(sys.argv[1] if len(sys.argv) > 1 else "steps/tmp.json")
|
||||
actions = json.loads(path.read_text(encoding="utf-8"))
|
||||
tool = WebPageTool()
|
||||
msg, artifact = tool._run(actions)
|
||||
print(msg)
|
||||
png = artifact.get("image_bytes")
|
||||
if png:
|
||||
out = Path(__file__).parent / "output"
|
||||
out.mkdir(exist_ok=True)
|
||||
name = path.stem + f"_{int(time.time())}.png"
|
||||
(out / name).write_bytes(png)
|
||||
print(f"[screenshot saved] {out / name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>WebPage 测试页</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 20px; }
|
||||
.ant-form-item { display: flex; align-items: center; margin: 8px 0; }
|
||||
.ant-form-item label { width: 90px; }
|
||||
.ant-calendar-picker { position: relative; display: inline-block; }
|
||||
.ant-calendar-picker-container {
|
||||
position: absolute; top: 40px; left: 0; z-index: 1000;
|
||||
background: #fff; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,.15);
|
||||
padding: 8px; display: none; width: 260px;
|
||||
}
|
||||
.ant-calendar-picker-container.open { display: block; }
|
||||
.ant-calendar-ym-select a { margin: 0 6px; cursor: pointer; }
|
||||
.ant-calendar-date { display: inline-block; width: 26px; height: 24px; line-height: 24px;
|
||||
text-align: center; cursor: pointer; }
|
||||
.ant-calendar-date:hover { background: #e6f7ff; }
|
||||
.ant-calendar-year-panel-cell, .ant-calendar-month-panel-cell {
|
||||
display: inline-block; width: 60px; height: 26px; line-height: 26px; text-align: center;
|
||||
cursor: pointer; }
|
||||
.ant-calendar-year-panel-cell a, .ant-calendar-month-panel-cell a { color: #333; text-decoration: none; }
|
||||
.ant-calendar-year-panel-prev-decade-btn, .ant-calendar-year-panel-next-decade-btn,
|
||||
.ant-calendar-prev-month-btn, .ant-calendar-next-month-btn { cursor: pointer; margin: 0 4px; }
|
||||
.ant-select-dropdown { display: none; border: 1px solid #ccc; background: #fff; max-height: 200px; overflow: auto; }
|
||||
.ant-select-dropdown.open { display: block; }
|
||||
.ant-select-dropdown-menu-item { padding: 4px 12px; cursor: pointer; }
|
||||
.ant-select-dropdown-menu-item:hover { background: #e6f7ff; }
|
||||
.dialog { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 500; }
|
||||
.dialog.open { display: flex; align-items: center; justify-content: center; }
|
||||
.dialog-box { background: #fff; padding: 20px; width: 380px; border-radius: 6px; }
|
||||
.popup { display: none; position: fixed; background: #fff; border: 1px solid #999;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.2); padding: 12px; z-index: 600; }
|
||||
.popup.open { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WebPage 测试页</h1>
|
||||
|
||||
<!-- 登录区 -->
|
||||
<div class="ant-form-item"><label>账户名</label><input placeholder="账户名"></div>
|
||||
<div class="ant-form-item"><label>密码</label><input type="password" placeholder="密码"></div>
|
||||
<button id="btn-login">登录</button>
|
||||
<span id="login-result"></span>
|
||||
|
||||
<!-- 打开弹窗按钮 -->
|
||||
<button id="btn-dialog">打开弹窗</button>
|
||||
|
||||
<!-- 弹窗:表单 + 下拉 + 纯文本标题 -->
|
||||
<div class="dialog" id="my-dialog" role="dialog" aria-modal="true">
|
||||
<div class="dialog-box">
|
||||
<button class="close" onclick="closeDialog()">Close</button>
|
||||
<div class="ant-form-item"><label>姓名</label><input id="dlg-name" placeholder="姓名"></div>
|
||||
<div class="ant-form-item">
|
||||
<label>产品</label>
|
||||
<div class="ant-select-selection" onclick="toggleSelect(this)">请选择产品</div>
|
||||
<div class="ant-select-dropdown">
|
||||
<div class="result-title" onclick="">检索结果</div>
|
||||
<ul style="list-style:none;margin:0;padding:0">
|
||||
<li class="ant-select-dropdown-menu-item">意外险</li>
|
||||
<li class="ant-select-dropdown-menu-item">健康险</li>
|
||||
<li class="ant-select-dropdown-menu-item">060003旅游意外伤害保险</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button id="dlg-ok" onclick="confirmDialog()">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日期控件(antd 1.x 结构模拟:点击输入框弹出面板) -->
|
||||
<div class="ant-form-item">
|
||||
<label>证件有效起期</label>
|
||||
<span class="ant-calendar-picker">
|
||||
<input class="ant-calendar-picker-input" placeholder="证件有效起期" readonly onclick="openCalendar(this)">
|
||||
<div class="ant-calendar-picker-container">
|
||||
<div class="ant-calendar-header">
|
||||
<a class="ant-calendar-prev-month-btn" title="上个月" onclick="navMonth(-1)">‹</a>
|
||||
<span class="ant-calendar-ym-select">
|
||||
<a class="ant-calendar-month-select" title="选择月份" onclick="showMonthPanel()">8月</a>
|
||||
<a class="ant-calendar-year-select" title="选择年份" onclick="showYearPanel()">2026年</a>
|
||||
</span>
|
||||
<a class="ant-calendar-next-month-btn" title="下个月" onclick="navMonth(1)">›</a>
|
||||
</div>
|
||||
<div class="ant-calendar-date-panel" id="date-panel"></div>
|
||||
<div class="ant-calendar-year-panel" id="year-panel" style="display:none"></div>
|
||||
<div class="ant-calendar-month-panel" id="month-panel" style="display:none"></div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- shadow DOM 组件 -->
|
||||
<x-widget></x-widget>
|
||||
|
||||
<!-- 机构树(angular-tree-component / ant-tree 结构模拟:switcher + content-wrapper + title) -->
|
||||
<div class="ant-form-item"><label>机构</label>
|
||||
<div id="org-tree" class="angular-tree-component">
|
||||
<div class="tree-children">
|
||||
<div class="tree-node">
|
||||
<span class="ant-tree-switcher ant-tree-switcher-open" onclick="toggleOrg(this)">▾</span>
|
||||
<span class="ant-tree-node-content-wrapper">
|
||||
<span class="ant-tree-title">北京分公司</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tree-node" style="padding-left: 20px">
|
||||
<span class="ant-tree-switcher ant-tree-switcher-noop">·</span>
|
||||
<span class="ant-tree-node-content-wrapper">
|
||||
<span class="ant-tree-title">北京分公司车险管理部</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tree-node">
|
||||
<span class="ant-tree-switcher ant-tree-switcher-close" onclick="toggleOrg(this)">▸</span>
|
||||
<span class="ant-tree-node-content-wrapper">
|
||||
<span class="ant-tree-title">上海分公司</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span id="org-selected"></span>
|
||||
|
||||
<script>
|
||||
// 树节点点击选中(title 可点)
|
||||
document.querySelectorAll('#org-tree .ant-tree-title').forEach(function (t) {
|
||||
t.addEventListener('click', function () {
|
||||
document.getElementById('org-selected').textContent = '已选: ' + t.textContent.trim();
|
||||
});
|
||||
});
|
||||
// 树 switcher 点击仅展开/折叠
|
||||
window.toggleOrg = function (sw) {
|
||||
var node = sw.closest('.tree-node');
|
||||
var kids = node.querySelector('.tree-children');
|
||||
var folded = sw.classList.toggle('ant-tree-switcher-close');
|
||||
if (kids) kids.style.display = folded ? 'none' : 'block';
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// ---------- 弹窗 ----------
|
||||
window.openDialog = function () { document.getElementById('my-dialog').classList.add('open'); };
|
||||
window.closeDialog = function () { document.getElementById('my-dialog').classList.remove('open'); };
|
||||
window.confirmDialog = function () {
|
||||
var v = document.getElementById('dlg-name').value;
|
||||
document.getElementById('login-result').textContent = '确认:' + v;
|
||||
closeDialog();
|
||||
};
|
||||
document.getElementById('btn-dialog').addEventListener('click', openDialog);
|
||||
document.getElementById('btn-login').addEventListener('click', function () {
|
||||
document.getElementById('login-result').textContent = '登录成功';
|
||||
});
|
||||
|
||||
// ---------- 模拟 antd select ----------
|
||||
window.toggleSelect = function (sel) {
|
||||
var dd = sel.parentElement.querySelector('.ant-select-dropdown');
|
||||
dd.classList.toggle('open');
|
||||
};
|
||||
document.querySelectorAll('.ant-select-dropdown-menu-item').forEach(function (li) {
|
||||
li.addEventListener('click', function () {
|
||||
var sel = li.closest('.ant-form-item').querySelector('.ant-select-selection');
|
||||
sel.textContent = li.textContent;
|
||||
li.closest('.ant-select-dropdown').classList.remove('open');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 模拟 antd 日历 ----------
|
||||
var curYear = 2026, curMonth = 8, selYear = 2026, selMonth = 8, selDay = 20;
|
||||
window.renderDate = function () {
|
||||
var p = document.getElementById('date-panel');
|
||||
var first = new Date(curYear, curMonth - 1, 1).getDay();
|
||||
var days = new Date(curYear, curMonth, 0).getDate();
|
||||
var html = '';
|
||||
for (var i = 0; i < first; i++) html += '<span class="ant-calendar-date" style="visibility:hidden">-</span>';
|
||||
for (var d = 1; d <= days; d++) {
|
||||
html += '<span class="ant-calendar-date" onclick="pickDate(' + d + ')">' + d + '</span>';
|
||||
}
|
||||
p.innerHTML = html;
|
||||
document.querySelector('.ant-calendar-month-select').textContent = curMonth + '月';
|
||||
document.querySelector('.ant-calendar-year-select').textContent = curYear + '年';
|
||||
};
|
||||
window.openCalendar = function (inp) {
|
||||
var c = inp.parentElement.querySelector('.ant-calendar-picker-container');
|
||||
var open = c.classList.toggle('open');
|
||||
if (open) { renderDate(); showDatePanel(); }
|
||||
};
|
||||
window.showDatePanel = function () {
|
||||
document.getElementById('date-panel').style.display = 'block';
|
||||
document.getElementById('year-panel').style.display = 'none';
|
||||
document.getElementById('month-panel').style.display = 'none';
|
||||
};
|
||||
window.showYearPanel = function () {
|
||||
var p = document.getElementById('year-panel');
|
||||
var start = Math.floor(curYear / 10) * 10;
|
||||
var html = '<a class="ant-calendar-year-panel-prev-decade-btn" onclick="navDecade(-1)">«</a> ';
|
||||
for (var y = start; y < start + 10; y++) {
|
||||
html += '<span class="ant-calendar-year-panel-cell"><a onclick="pickYear(' + y + ')">' + y + '</a></span>';
|
||||
}
|
||||
html += ' <a class="ant-calendar-year-panel-next-decade-btn" onclick="navDecade(1)">»</a>';
|
||||
p.innerHTML = html;
|
||||
document.getElementById('date-panel').style.display = 'none';
|
||||
document.getElementById('year-panel').style.display = 'block';
|
||||
document.getElementById('month-panel').style.display = 'none';
|
||||
};
|
||||
window.navDecade = function (d) {
|
||||
var start = Math.floor(curYear / 10) * 10 + d * 10;
|
||||
curYear = start;
|
||||
showYearPanel();
|
||||
};
|
||||
window.showMonthPanel = function () {
|
||||
var p = document.getElementById('month-panel');
|
||||
var html = '';
|
||||
for (var m = 1; m <= 12; m++) {
|
||||
html += '<span class="ant-calendar-month-panel-cell"><a onclick="pickMonth(' + m + ')">' + m + '月</a></span>';
|
||||
}
|
||||
p.innerHTML = html;
|
||||
document.getElementById('date-panel').style.display = 'none';
|
||||
document.getElementById('year-panel').style.display = 'none';
|
||||
document.getElementById('month-panel').style.display = 'block';
|
||||
};
|
||||
window.pickYear = function (y) {
|
||||
curYear = y; showDatePanel(); renderDate();
|
||||
};
|
||||
window.pickMonth = function (m) {
|
||||
curMonth = m; showDatePanel(); renderDate();
|
||||
};
|
||||
window.navMonth = function (d) {
|
||||
curMonth += d;
|
||||
if (curMonth > 12) { curMonth = 1; curYear++; }
|
||||
if (curMonth < 1) { curMonth = 12; curYear--; }
|
||||
renderDate();
|
||||
};
|
||||
window.pickDate = function (d) {
|
||||
selYear = curYear; selMonth = curMonth; selDay = d;
|
||||
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
||||
var inp = document.querySelector('.ant-calendar-picker-input');
|
||||
inp.value = selYear + '-' + pad(selMonth) + '-' + pad(selDay);
|
||||
document.querySelector('.ant-calendar-picker-container').classList.remove('open');
|
||||
};
|
||||
renderDate();
|
||||
|
||||
// ---------- shadow DOM ----------
|
||||
class XWidget extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const root = this.attachShadow({ mode: 'open' });
|
||||
root.innerHTML = '<button id="x-btn">影子按钮</button><input id="x-input" placeholder="影子输入框">';
|
||||
}
|
||||
}
|
||||
customElements.define('x-widget', XWidget);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,134 @@
|
||||
"""web_page 工具冒烟测试(fixture 本地页面,覆盖本次改动核心能力)。
|
||||
|
||||
运行: python Agent/tests/web_page/smoke_test.py
|
||||
依赖: 无浏览器时自动分离启动(browser_control 逻辑)
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parents[2] / "Agent" / "src"))
|
||||
|
||||
from agent.tools.web_page import WebPage as WebPageTool # noqa: E402
|
||||
|
||||
PORT = 8766
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def check(name: str, cond: bool, detail: str = "") -> None:
|
||||
global PASS, FAIL
|
||||
if cond:
|
||||
PASS += 1
|
||||
print(f"[PASS] {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f"[FAIL] {name} {detail}")
|
||||
|
||||
|
||||
class NoCacheHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
super().end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def serve() -> None:
|
||||
handler = lambda *a, **kw: NoCacheHandler(*a, directory=str(HERE / "fixture"), **kw)
|
||||
http.server.ThreadingHTTPServer(("127.0.0.1", PORT), handler).serve_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
threading.Thread(target=serve, daemon=True).start()
|
||||
|
||||
tool = WebPageTool()
|
||||
|
||||
def run(actions: list[dict]) -> str:
|
||||
return tool._run(actions)[0]
|
||||
|
||||
# 1. 打开 fixture 页面
|
||||
msg = run([{"action": "goto", "url": f"http://127.0.0.1:{PORT}/index.html"}])
|
||||
check("goto fixture", "已打开" in msg, msg)
|
||||
|
||||
# 2. 登录区元素
|
||||
msg = run([{"action": "snapshot"}])
|
||||
check("快照含账户名", '"账户名"' in msg, msg[:200])
|
||||
check("快照含密码", '"密码"' in msg)
|
||||
check("快照含登录按钮", 'button "登录"' in msg)
|
||||
|
||||
# 3. 打开弹窗 → 层级断言
|
||||
msg = run([
|
||||
{"action": "click", "selector": "text=\"打开弹窗\""},
|
||||
{"action": "wait", "wait_seconds": 0.3},
|
||||
{"action": "snapshot"},
|
||||
])
|
||||
check("弹窗标注 [弹窗]", "[弹窗]" in msg, msg[:300])
|
||||
check("弹窗含 Close", 'button "Close"' in msg)
|
||||
check("弹窗含确认按钮", 'button "确认"' in msg)
|
||||
|
||||
# 4. 展开下拉 → 选项进快照 + 纯文本标题标注
|
||||
msg = run([
|
||||
{"action": "click", "selector": "text=\"请选择产品\"", "note": "展开下拉"},
|
||||
{"action": "wait", "wait_seconds": 0.3},
|
||||
{"action": "snapshot"},
|
||||
])
|
||||
check("纯文本容器标 [文本]", "[文本]" in msg, msg[:300])
|
||||
check("下拉选项进快照", "意外险" in msg and "060003旅游意外伤害保险" in msg, msg[:400])
|
||||
|
||||
# 5. text= 精确匹配点击下拉选项
|
||||
msg = run([{"action": "click", "selector": "text=\"意外险\"", "note": "选项"}])
|
||||
check("text= 点选项成功", "已点击" in msg, msg)
|
||||
msg = run([{"action": "eval_js", "js": "document.querySelector('.ant-select-selection').textContent"}])
|
||||
check("下拉选中意外险", "意外险" in msg, msg)
|
||||
|
||||
# 6. date 动作:2005-07-09(年份面板需翻两个十年;先关闭弹窗避免遮挡)
|
||||
msg = run([
|
||||
{"action": "click", "selector": "text=\"Close\"", "note": "关弹窗"},
|
||||
{"action": "wait", "wait_seconds": 0.3},
|
||||
{"action": "date", "selector": "input.ant-calendar-picker-input", "value": "2005-07-09"},
|
||||
])
|
||||
check("date 动作成功", "已选择日期: 2005-07-09" in msg, msg)
|
||||
msg = run([{"action": "eval_js", "js": "document.querySelector('.ant-calendar-picker-input').value"}])
|
||||
check("date 写入输入框", "2005-07-09" in msg, msg)
|
||||
|
||||
# 7. shadow DOM 穿透
|
||||
msg = run([{"action": "snapshot", "max_elements": 100}])
|
||||
check("shadow DOM 按钮可见", "影子按钮" in msg, msg[:300])
|
||||
|
||||
# 8. text= 精确匹配失败 → 自动子串匹配报告真实候选
|
||||
# (打开弹窗使下拉选项可见:text=旅游 精确匹配不到,子串候选含 060003旅游意外伤害保险)
|
||||
msg = run([
|
||||
{"action": "click", "selector": "text=\"打开弹窗\""},
|
||||
{"action": "wait", "wait_seconds": 0.3},
|
||||
{"action": "click", "selector": "text=旅游"},
|
||||
])
|
||||
check("text= 失败给出候选报告", "候选" in msg and "060003旅游" in msg, msg[:300])
|
||||
|
||||
# 9. 树节点:switcher 标 [展开],title 正常显示且可点击选中
|
||||
msg = run([{"action": "snapshot", "max_elements": 200}])
|
||||
check("树 switcher 标 [展开]", "[展开]" in msg, msg[:400])
|
||||
check("树节点标题可见", "北京分公司车险管理部" in msg, msg[:400])
|
||||
msg = run([
|
||||
{"action": "click", "selector": "text=\"Close\"", "note": "关弹窗"},
|
||||
{"action": "wait", "wait_seconds": 0.3},
|
||||
{"action": "click", "selector": "text=\"北京分公司车险管理部\"", "note": "树节点标题"},
|
||||
])
|
||||
check("树节点标题可点击", "已点击" in msg, msg)
|
||||
msg = run([{"action": "eval_js", "js": "document.getElementById('org-selected').textContent"}])
|
||||
check("树节点点击选中生效", "已选: 北京分公司车险管理部" in msg, msg)
|
||||
|
||||
print()
|
||||
if FAIL:
|
||||
print(f"✗ {FAIL} 项失败 / {PASS} 项通过")
|
||||
sys.exit(1)
|
||||
print(f"全部通过 ✓({PASS} 项断言)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{"action": "goto", "url": "http://117.78.36.208:9099/"},
|
||||
{"action": "wait", "wait_seconds": 3},
|
||||
{"action": "snapshot"},
|
||||
{"action": "fill", "ref": 1, "text": "10010381"},
|
||||
{"action": "fill", "ref": 2, "text": "uat"},
|
||||
{"action": "click", "ref": 5},
|
||||
{"action": "wait", "wait_seconds": 3},
|
||||
{"action": "snapshot"},
|
||||
{"action": "click", "ref": 5},
|
||||
{"action": "wait", "wait_seconds": 4},
|
||||
{"action": "snapshot"}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"action": "goto", "url": "http://127.0.0.1:8766/index.html"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=产品"}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 50}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300}
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"action": "click", "ref": 37, "note": "业务来源(已是直销业务,重选验证)"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=\"直销业务\""}
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "click", "ref": 37, "note": "业务来源"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=\"直销业务\"", "note": "业务来源选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "ref": 39, "note": "渠道中级分类"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=\"传统直销业务\"", "note": "中级分类选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "ref": 41, "note": "渠道子类"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=\"员工直销\"", "note": "渠道子类选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).map(e => e.textContent.trim().slice(0, 15)); return JSON.stringify(nz.filter(t => ['直销业务', '传统直销业务', '员工直销'].includes(t))); })()"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "date", "ref": 83, "value": "2005-07-09", "note": "证件有效起期"},
|
||||
{"action": "date", "ref": 84, "value": "2006-07-09", "note": "证件有效止期"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "eval_js", "js": "(() => { const a = document.querySelector('input[placeholder=\"证件有效起期\"]')?.value; const b = document.querySelector('input[placeholder=\"证件有效止期\"]')?.value; return '起期=' + a + ' 止期=' + b; })()"}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"action": "snapshot"},
|
||||
{"action": "click", "ref": 2, "note": "申请单录入"},
|
||||
{"action": "wait", "wait_seconds": 4},
|
||||
{"action": "eval_js", "js": "(() => { const m = document.querySelector('.ant-modal-wrap'); return m ? 'MODAL OPEN' : 'NO MODAL'; })()"},
|
||||
{"action": "snapshot", "max_elements": 40}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"action": "snapshot"},
|
||||
{"action": "click", "ref": 2, "note": "申请单录入"},
|
||||
{"action": "wait", "wait_seconds": 4},
|
||||
{"action": "snapshot", "max_elements": 40}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "eval_js", "js": "(() => { const b = document.getElementById('btn-dialog'); const r = document.getElementById('login-result'); return JSON.stringify({btnExists: !!b, resultText: r ? r.textContent : 'no-result', dialogOpen: document.getElementById('my-dialog') ? document.getElementById('my-dialog').className : 'no-dialog'}); })()"}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "goto", "url": "http://127.0.0.1:8766/index.html"}, {"action": "wait", "wait_seconds": 1}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "snapshot", "max_elements": 300}, {"action": "eval_js", "js": "(() => { const u = document.querySelector('#Applicant\\.CWorkDpt'); return '初始 disabled=' + (u ? u.getAttribute('ng-reflect-nz-disabled') : 'NF'); })()"}, {"action": "click", "ref": 78, "note": "投保人性质"}, {"action": "wait", "wait_seconds": 1}, {"action": "click", "selector": "text=\"个人\"", "note": "投保人性质选项"}, {"action": "wait", "wait_seconds": 1}, {"action": "eval_js", "js": "(() => { const u = document.querySelector('#Applicant\\.CWorkDpt'); return '选个人后 disabled=' + (u ? u.getAttribute('ng-reflect-nz-disabled') : 'NF'); })()"}]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"action": "snapshot"},
|
||||
{"action": "click", "ref": 1, "note": "系统菜单"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "snapshot", "max_elements": 40}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"action": "snapshot"},
|
||||
{"action": "click", "ref": 1, "note": "系统菜单"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "eval_js", "js": "(() => { const m = document.querySelector('.ant-modal-wrap'); return m ? 'MODAL OPEN' : 'NO MODAL'; })()"},
|
||||
{"action": "snapshot", "max_elements": 40}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 40},
|
||||
{"action": "click", "ref": 4, "note": "承保机构放大镜"},
|
||||
{"action": "wait", "wait_seconds": 3},
|
||||
{"action": "snapshot", "max_elements": 60}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 120},
|
||||
{"action": "click", "selector": "text=\"北京分公司车险管理部\"", "note": "机构节点"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "snapshot", "max_elements": 30}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"action": "eval_js", "js": "(() => { const els = Array.from(document.querySelectorAll('div,span')).filter(e => (e.textContent||'').trim() === '检索结果' && e.children.length <= 3); return JSON.stringify(els.map(e => { const a = {}; for (const x of e.attributes) a[x.name] = x.value.slice(0, 40); return {tag: e.tagName, cls: e.className, attrs: a, kids: e.children.length}; })); })()"}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "snapshot", "max_elements": 300}, {"action": "click", "ref": 118, "note": "单位性质"}, {"action": "wait", "wait_seconds": 2}, {"action": "eval_js", "js": "(() => { const lis = Array.from(document.querySelectorAll('.ant-select-dropdown-menu-item')).filter(e => e.offsetParent); return JSON.stringify(lis.slice(0, 8).map(e => e.textContent.trim().replace(/\\s+/g, ' ').slice(0, 40))); })()"}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "eval_js", "js": "(() => { const out = []; Array.from(document.querySelectorAll('nz-select')).forEach(e => { const t = (e.textContent||'').trim().replace(/\\s+/g, ' ').slice(0, 25); if (t) out.push(t); }); return JSON.stringify(out); })()"}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "eval_js", "js": "(() => { const all = Array.from(document.querySelectorAll('nz-select')); const u = all.find(e => (e.getAttribute('id')||'').includes('WorkDpt') || (e.getAttribute('ng-reflect-name')||'').includes('WorkDpt')); if (!u) return 'NOT FOUND, ids=' + JSON.stringify(all.slice(0, 10).map(e => e.id)); return JSON.stringify({id: u.id, ngName: u.getAttribute('ng-reflect-name'), disabled: u.getAttribute('ng-reflect-nz-disabled'), clsDisabled: (u.className||'').includes('ant-select-disabled'), text: (u.textContent||'').trim().slice(0, 20)}); })()"}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "eval_js", "js": "(() => {\n // 通讯地址-市下拉展开后看选项 li 文本\n const lis = Array.from(document.querySelectorAll('.ant-select-dropdown-menu-item')).map(e => JSON.stringify(e.textContent.trim().slice(0, 20)));\n return '当前可见 li: ' + lis.join(', ');\n})()"}]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 60},
|
||||
{"action": "fill", "ref": 2, "text": "车险管理部"},
|
||||
{"action": "press", "key": "Enter"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "snapshot", "max_elements": 60}
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).map(e => (e.textContent||'').trim().replace(/\\s+/g, ' ').slice(0, 20)); const keys = ['业务来源', '渠道中级分类', '渠道子类', '机构部门', '产品']; return JSON.stringify({all: nz}); })()"}]
|
||||
@@ -0,0 +1 @@
|
||||
[{"action": "eval_js", "js": "(() => {\n const out = {legends: [], expands: [], unitDisabled: null, addrVisible: null};\n // 1) 所有 fieldset legend 标题 + 展开链接状态\n out.legends = Array.from(document.querySelectorAll('fieldset > legend')).map(l => {\n const fs = l.closest('fieldset');\n const a = l.querySelector('a');\n return {\n title: (l.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 30),\n link: a ? a.textContent.trim() : null,\n bodyHidden: fs ? getComputedStyle(fs.querySelector('div, table, .ant-table, form') || fs).display : '?'\n };\n });\n // 2) 通讯地址省下拉可见性\n const addr = Array.from(document.querySelectorAll('nz-select')).find(e => (e.textContent||'').includes('省'));\n out.addrVisible = addr ? (addr.offsetParent !== null ? 'VISIBLE' : 'HIDDEN') : 'NOT FOUND';\n // 3) 单位性质禁用\n const unit = document.querySelector('#Applicant\\.CWorkDpt, [id=\"Applicant.CWorkDpt\"]');\n out.unitDisabled = unit ? (unit.getAttribute('ng-reflect-nz-disabled') || unit.classList.contains('ant-select-disabled') || '?') : 'NOT FOUND';\n // 4) 8 个展开链接的父级标题\n out.expands = Array.from(document.querySelectorAll('a.legrnd-ta')).map(a => {\n const leg = a.closest('legend');\n return (leg ? leg.textContent.trim().replace(/\\s+/g, ' ').slice(0, 30) : 'no-legend');\n });\n return JSON.stringify(out);\n})()"}]
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"action": "snapshot"},
|
||||
{"action": "click", "ref": 2, "note": "申请单录入"},
|
||||
{"action": "wait", "wait_seconds": 4},
|
||||
{"action": "snapshot", "max_elements": 40},
|
||||
{"action": "click", "ref": 4, "note": "承保机构放大镜"},
|
||||
{"action": "wait", "wait_seconds": 3},
|
||||
{"action": "snapshot", "max_elements": 120}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{"action": "goto", "url": "http://117.78.36.208:9099/"},
|
||||
{"action": "wait", "wait_seconds": 3},
|
||||
{"action": "snapshot"},
|
||||
{"action": "click", "ref": 2, "note": "申请单录入"},
|
||||
{"action": "wait", "wait_seconds": 4},
|
||||
{"action": "snapshot", "max_elements": 40},
|
||||
{"action": "click", "ref": 4, "note": "承保机构放大镜"},
|
||||
{"action": "wait", "wait_seconds": 3},
|
||||
{"action": "snapshot", "max_elements": 120}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "click", "ref": 54, "note": "单位性质"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "click", "selector": "text=机关团体"}
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')); const u = nz.find(e => (e.textContent||'').includes('单位性质') || (e.getAttribute('ng-reflect-name')||'').includes('Unit')); return '单位性质nz-select: ' + (u ? u.getAttribute('ng-reflect-name') : '未按名找到'); })()"}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "click", "ref": 118, "note": "单位性质"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "click", "selector": "text=机关团体"}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "click", "ref": 118, "note": "单位性质"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "click", "selector": "text=\"机关团体-社会团体\"", "note": "单位性质选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).filter(e => (e.textContent||'').includes('机关团体-社会团体')); return '选中: ' + nz.length + ' 个'; })()"}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "click", "ref": 118, "note": "单位性质"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "click", "selector": "text=\"机关团体-社会团体\"", "note": "单位性质选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).filter(e => (e.textContent||'').includes('机关团体')); return '含机关团体 select 数: ' + nz.length; })()"}
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "click", "selector": "nz-select:has([ng-reflect-name]) >> text=\"机关团体\"", "note": "定位单位性质"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "eval_js", "js": "(() => { const lis = Array.from(document.querySelectorAll('.ant-select-dropdown-menu-item')).filter(e => e.offsetParent).map(e => e.textContent.trim().slice(0, 25)); return JSON.stringify(lis.slice(0, 8)); })()"}
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 300},
|
||||
{"action": "click", "ref": 118, "note": "单位性质"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "click", "selector": "text=\"机关团体-社会团体\"", "note": "单位性质选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).filter(e => (e.textContent||'').includes('机关团体-社会团体')); return '选中数: ' + nz.length; })()"}
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
[
|
||||
{"action": "snapshot", "max_elements": 60},
|
||||
{"action": "fill", "ref": 2, "text": ""},
|
||||
{"action": "press", "key": "Enter"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "click", "selector": "text=\"北京分公司车险管理部\"", "note": "机构树节点"},
|
||||
{"action": "wait", "wait_seconds": 2},
|
||||
{"action": "snapshot", "max_elements": 40},
|
||||
{"action": "click", "ref": 5, "note": "产品大类"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "type", "text": "意外险"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=\"意外险\"", "note": "产品大类选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "ref": 7, "note": "产品"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "type", "text": "060003"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=\"060003旅游意外伤害保险\"", "note": "产品选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "ref": 11, "note": "联共保业务"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "selector": "text=\"司内联保_主联\"", "note": "联共保选项"},
|
||||
{"action": "wait", "wait_seconds": 1},
|
||||
{"action": "click", "ref": 15, "note": "确认"},
|
||||
{"action": "wait", "wait_seconds": 6},
|
||||
{"action": "snapshot", "max_elements": 20}
|
||||
]
|
||||