You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

279 lines
17 KiB

4 hours ago
# -*- coding: utf-8 -*-
"""a7-heavy · demo 浏览器级 E2E(票 05:补码三域冒烟,Playwright chromium headless)
被测: .scratch/a7-heavy/demo/index.html?api=http://localhost:8080ts= 注入脚本侧时间戳两侧一致
布局: 3 tab 同步任务(票01) / 全局日志(票02) / 公海绑定数(票03)
覆盖: 票01 任务化契约(SYNC-MMdd-序号/61023 两态容忍/分页倒序/详情五块/设置读写+61022 双路+还原/
lastSyncTime 切表口径/下次执行时间推导) + 票02 AOP 采集行/退出埋点/导出导入留档/结果文件 ZIP/
ids[] 打包/不存在文件错误形态/BUDDY 403 + 票03 部门专用规则发布/增量计数 base+n/默认兜底降级/清理正门
附带断言: 网络层零 PUT/DELETE /api/<module>/<digits>/ 路径变量console/page error
清理: pre_clean 幂等演示角色删 / 规则草稿删+发布中停用 / 导入池删
任务行与登录/操作/批量日志行 = 审计数据留存不删同步设置由 demo 步骤7 自还原原值
商机 close 正门终态已关闭仍计数 增量断言 base+n 跨轮安全
"""
import json, re, time, pathlib, sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).resolve().parent
SHOTS = ROOT / "shots"; SHOTS.mkdir(exist_ok=True)
DEMO = ROOT / "demo" / "index.html"
API = "http://localhost:8080"
ADMIN = "739564171091247104"
BUDDY = "744842318024015872"
TS = time.strftime("%H%M%S")
results = []
def step(name, ok, note=""):
results.append({"step": name, "ok": bool(ok), "note": note})
print(("PASS " if ok else "FAIL ") + name + (" | " + note if note else ""))
import urllib.request, urllib.parse, urllib.error
def post(url, data=None, token=None):
body = urllib.parse.urlencode(data or {}).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
if token: req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return {"code": -1, "message": f"HTTP {e.code}"}
def get(url, token=None):
req = urllib.request.Request(url)
if token: req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return {"code": -1, "message": f"HTTP {e.code}"}
def token_of(uid=ADMIN):
return json.loads(urllib.request.urlopen(f"{API}/api/auth/debug/token?userId={uid}", timeout=15).read().decode())["data"]
def pre_clean():
"""中断轮次残留清理(幂等重跑保障)。
留守原则任务行与登录/操作/批量日志行 = 审计数据留存不删设置由 demo 步骤7 还原"""
try: tok = token_of()
except Exception as ex: print("pre_clean token fail:", ex); return
removed = {"roles": 0, "rules_deleted": 0, "rules_disabled": 0, "pools": 0}
# 演示角色(logPrepare 正常轮次自建自删,此处兜中断残留)
for kw in ("e2d-日志演示角色",):
pg = post(f"{API}/api/roles/page", {"current": 1, "size": 100, "roleName": kw}, tok)
for r in (pg.get("data") or {}).get("content", []):
if post(f"{API}/api/roles/delete", {"id": str(r["id"])}, tok).get("code") == 0:
removed["roles"] += 1
# 绑定数演示规则:草稿删 / 发布中停用(停用行留痕可辨,同 demo prPrepare 幂等口径)
pg = post(f"{API}/api/rule/opp-pool-rule/page", {"current": 1, "size": 100, "ruleName": "e2d-绑定数规则"}, tok)
for r in (pg.get("data") or {}).get("content", []):
if not str(r.get("ruleName", "")).startswith("e2d-绑定数规则"): continue
if str(r.get("status")) == "1" and post(f"{API}/api/rule/opp-pool-rule/delete", {"id": str(r["id"])}, tok).get("code") == 0:
removed["rules_deleted"] += 1
elif str(r.get("status")) == "2" and post(f"{API}/api/rule/opp-pool-rule/disable", {"id": str(r["id"])}, tok).get("code") == 0:
removed["rules_disabled"] += 1
# 导入池(e2d-导入池;上轮已 close 不存在——池本体删除即净)
pg = post(f"{API}/api/rule/pool/page", {"current": 1, "size": 100, "poolName": "e2d-导入池"}, tok)
for r in (pg.get("data") or {}).get("content", []):
if str(r.get("poolName", "")).startswith("e2d-导入池") and post(f"{API}/api/rule/pool/delete", {"id": str(r["id"])}, tok).get("code") == 0:
removed["pools"] += 1
print("pre_clean:", removed)
pre_clean()
console_errors, page_errors, api_urls = [], [], []
def run_step(page, tab, label=None, btn=None, outsel=None, timeout=30000):
"""切到 tab → 清空旧输出防串步 → 点步骤按钮 → 等 out 容器出现 okbox/errbox/infobox"""
outsel = outsel or f"#{tab}Out"
page.click(f"nav.tabs button[data-tab='{tab}']")
page.evaluate(f"document.querySelector('{outsel}').innerHTML = ''")
page.click(btn or f"#tab-{tab} button:has-text('{label}')")
page.wait_for_selector(f"{outsel} .okbox, {outsel} .errbox, {outsel} .infobox", timeout=timeout)
cls = page.get_attribute(f"{outsel} > div", "class")
text = page.text_content(f"{outsel} > div").strip()
is_ok = "okbox" in cls or "infobox" in cls
return is_ok, text
def free_depts(tok):
"""树序部门 id 中未被线索池占用的(导入池 xlsx 用 free[0];demo 无建池步骤,无错开需求)"""
tree = get(f"{API}/api/system/depts/tree", tok).get("data") or []
ids = []
def walk(ns):
for n in ns or []:
ids.append(str(n["id"])); walk(n.get("children"))
walk(tree)
pg = post(f"{API}/api/rule/pool/page", {"current": 1, "size": 200}, tok)
used = {str(r["deptId"]) for r in (pg.get("data") or {}).get("content", []) if r.get("deptId")}
return [i for i in ids if i not in used]
def make_import_xlsx(ts, dept_id):
"""12 列模板与 LeadPoolImportRow @ExcelProperty 逐字一致;1 行合法数据(部门=空闲部门顺位避让 64003)"""
import openpyxl
wb = openpyxl.Workbook(); ws = wb.active
ws.append(["池名称", "归属部门ID", "部门名称", "负责人用户ID", "负责人姓名",
"协作人用户ID(逗号分隔)", "部门成员用户ID(逗号分隔)",
"领取规则(1=成员可领+管理员分配 2=仅管理员分配)", "每日领取上限",
"个人持有上限", "超时回收天数", "线索失效天数"])
ws.append([f"e2d-导入池-{ts}", dept_id, "e2d部门", ADMIN, "罗伟健",
"", BUDDY, 1, 5, 100, 7, 30])
p = ROOT / f"_log_import_{ts}.xlsx"
wb.save(p)
return p
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_default_timeout(20000)
page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: page_errors.append(str(e)))
def on_resp(r):
u = r.url
if "/api/" in u:
api_urls.append(f"{r.request.method} {u}")
page.on("response", on_resp)
# 0. 打开 demo(ts 参数注入脚本侧时间戳,保证演示角色/规则/商机/导入池名两侧一致)
page.goto(DEMO.as_uri() + "?api=" + API + "&ts=" + TS)
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SHOTS / "00-open.png"))
# 1. 登录(登录后 demo 自动预载同步任务列表)
page.click("button:has-text('uid 登录')")
page.wait_for_selector(".toast-item:has-text('登录成功')", timeout=15000)
step("登录 debug/token + /auth/me", True)
# ===== Tab 同步任务(票 01)=====
# 步骤1 发起同步(61023 两态容忍;异步终态轮询最长 75s → 步骤超时放大到 100s)
is_ok, text = run_step(page, "sync", "发起同步任务", timeout=100000)
step("同步任务 launch(任务编号格式 + 61023 两态容忍)",
is_ok and ("任务化契约验证" in text or "61023 并发占位生效" in text), text[:100].replace("\n", " "))
page.screenshot(path=str(SHOTS / "01-sync-launch.png"))
# 步骤2 分页+倒序(syncPage 渲染 #sBody 不走 out 容器,单独驱动;window._syncRows 服务端原序断言倒序)
page.click("nav.tabs button[data-tab='sync']")
page.click("#tab-sync .viewbar button:has-text('查询')")
page.wait_for_selector("#sBody tr td:not(.empty)", state="attached", timeout=15000)
rows = page.evaluate("() => window._syncRows || []")
times = [str(r.get("startTime") or "") for r in rows]
desc = all(times[i] >= times[i + 1] for i in range(len(times) - 1))
step("同步记录分页 + 开始时间倒序", len(rows) > 0 and desc,
f"rows={len(rows)} desc={desc}" + (f" first={times[0]}" if times else ""))
# 步骤3 详情五块(taskNo/triggerType/status/startTime/progress + failDetails 三字段)
is_ok, text = run_step(page, "sync", "任务详情")
step("任务详情五块结构(含 failDetails 失败明细)", is_ok and "详情五块结构在位" in text, text[:120].replace("\n", " "))
# 步骤4-7 设置读/存/边界/还原
is_ok, text = run_step(page, "sync", "读取同步设置")
step("同步设置回显(派生 plan/nextExecuteTime/lastModifierName)", is_ok and "设置回显" in text, text[:120].replace("\n", " "))
page.screenshot(path=str(SHOTS / "02-sync-settings.png"))
is_ok, text = run_step(page, "sync", "保存合法设置")
step("设置保存回显(interval=2 次数=3 并发=1)", is_ok and "合法设置保存并回显" in text, text[:90])
is_ok, text = run_step(page, "sync", "边界校验")
step("设置边界 61022 双路(间隔<1min / 次数<1 拒)", is_ok and "61022 边界校验两路生效" in text, text[:100].replace("\n", " "))
is_ok, text = run_step(page, "sync", "还原设置")
step("设置还原(不污染调度原值)", is_ok and "设置已还原" in text, text[:90])
# 步骤8 lastSyncTime 切表口径 + 下次执行时间推导
is_ok, text = run_step(page, "sync", "lastSyncTime 数据源切换")
step("lastSyncTime 切表口径 + 下次执行时间推导", is_ok and "lastSyncTime 验证" in text, text[:130].replace("\n", " "))
page.screenshot(path=str(SHOTS / "03-sync-stats.png"))
# ===== Tab 全局日志(票 02)=====
page.click("nav.tabs button[data-tab='glog']")
_tok = token_of()
_free = free_depts(_tok)
assert len(_free) >= 1, f"空闲部门不足: {_free}"
xlsx = make_import_xlsx(TS, _free[0])
page.set_input_files("#logImportFile", str(xlsx))
# 步骤1 AOP 素材(建/删演示角色;采集落库由步骤2 轮询等待)
is_ok, text = run_step(page, "glog", "建/删演示角色")
step("AOP 写端点采集素材(建/删演示角色)", is_ok and "写操作完成" in text, text[:100])
page.screenshot(path=str(SHOTS / "04-glog-prepare.png"))
# 步骤2 操作日志分页(operatorName/opType 命中 + 时间范围逐字段)
is_ok, text = run_step(page, "glog", "操作日志分页", timeout=40000)
step("操作日志分页(新增/删除行命中 + 时间范围逐字段)", is_ok and "AOP 采集行命中" in text, text[:130].replace("\n", " "))
# 步骤3 退出埋点(debug token logout 落「退出」行 + token 重铸)
is_ok, text = run_step(page, "glog", "退出登录", timeout=40000)
step("登录日志「退出」行(内联埋点 + token 重铸)", is_ok and "退出埋点命中" in text, text[:100].replace("\n", " "))
# 步骤4 线索池导出 → 批量「导出」行(result + 结果文件 id + ZIP 魔数)
is_ok, text = run_step(page, "glog", "线索池导出", timeout=40000)
step("批量日志「导出」行(result+fileId+ZIP 魔数)", is_ok and "导出留档行命中" in text, text[:120].replace("\n", " "))
page.screenshot(path=str(SHOTS / "05-glog-export.png"))
# 步骤5 导入 xlsx → 批量「导入」行(BatchResult 计数)
is_ok, text = run_step(page, "glog", "导入上方文件", timeout=40000)
step("批量日志「导入」行(BatchResult 计数)", is_ok and "导入行命中" in text, text[:120].replace("\n", " "))
# 步骤6 结果文件下载(/api/log/batch/file?id= ZIP 魔数)
is_ok, text = run_step(page, "glog", "结果文件下载")
step("结果文件下载 /api/log/batch/file(ZIP 魔数)", is_ok and "结果文件下载成功" in text, text[:90])
# 步骤7 打包下载(/api/log/batch/download ids[] ZIP 魔数)
is_ok, text = run_step(page, "glog", "打包下载")
step("打包下载 /api/log/batch/download ids[](ZIP 魔数)", is_ok and "打包下载成功" in text, text[:90])
# 步骤8 不存在文件 id 错误形态(40401/66001/HTTP404 容忍)
is_ok, text = run_step(page, "glog", "不存在文件 id")
step("不存在文件 id 错误形态(40401/66001/404)", is_ok and "错误形态生效" in text, text[:100].replace("\n", " "))
# 步骤9 BUDDY 三 page 权限拒绝
is_ok, text = run_step(page, "glog", "非管理员", timeout=40000)
step("BUDDY 三 page 权限拒绝(ADMIN 门禁)", is_ok and "非管理员权限口径生效" in text, text[:130].replace("\n", " "))
page.screenshot(path=str(SHOTS / "06-glog-denied.png"))
# ===== Tab 公海绑定数(票 03)=====
# 步骤1 建演示部门专用规则并发布(部门被占回退既有发布中专用规则)→ 基线 base
is_ok, text = run_step(page, "poolrule", "建演示部门专用规则", timeout=60000)
step("公海规则发布(部门专用)+ boundOppCount 基线", is_ok and "规则就绪" in text, text[:130].replace("\n", " "))
page.screenshot(path=str(SHOTS / "07-poolrule-base.png"))
# 步骤2/3 建演示商机 A/B → 增量断言 base+1 / base+2
is_ok, text = run_step(page, "poolrule", "建演示商机 A", timeout=60000)
step("商机 A → boundOppCount=base+1(match 语义)", is_ok and "base+1" in text, text[:110])
is_ok, text = run_step(page, "poolrule", "建演示商机 B", timeout=60000)
step("商机 B → boundOppCount=base+2(动态统计非快照)", is_ok and "base+2" in text, text[:110])
# 步骤4 默认规则兜底口径(无发布中默认规则 → infobox 降级提示)
is_ok, text = run_step(page, "poolrule", "默认规则兜底口径", timeout=60000)
step("默认规则兜底口径(无默认规则降级提示)", is_ok and ("兜底口径生效" in text or "降级为提示" in text), text[:120].replace("\n", " "))
# 步骤5 清理(商机 close 正门终态 / 演示规则停用)
is_ok, text = run_step(page, "poolrule", "关停演示商机", timeout=60000)
step("清理(商机 close 正门 + 规则停用)", is_ok and "清理完成" in text, text[:120].replace("\n", " "))
page.screenshot(path=str(SHOTS / "08-cleanup.png"))
# N. 网络层断言:零 PUT/DELETE、零路径变量形态
bad_method = [u for u in api_urls if u.startswith(("PUT ", "DELETE "))]
old_style = [u for u in api_urls if re.search(r"/api/[a-z-]+/\d+/", u)]
step("网络层零旧契约形态", not bad_method and not old_style,
f"bad_method={bad_method[:2]} old_style={old_style[:2]} (共{len(api_urls)}请求)")
browser.close()
# ---- 汇总 ----
xlsx.unlink(missing_ok=True)
# BUDDY 403 权限拒绝步骤会产生浏览器级 "Failed to load resource: 403" console error
# (HTTP 层 4xx 响应浏览器必然记录,JS 无法阻止)——属预期拒绝产物,豁免后仍要求真实 console error 为零
expected_403 = [t for t in console_errors if "status of 403" in t]
real_console = [t for t in console_errors if "status of 403" not in t]
step("console/page error 双零(预期 403 拒绝豁免)", not real_console and not page_errors,
f"raw_console={len(console_errors)} exempt_403={len(expected_403)} real={len(real_console)} page_errors={len(page_errors)}")
summary = {"ts": TS, "console_errors": console_errors, "page_errors": page_errors,
"exempt_403": expected_403, "real_console_errors": real_console,
"results": results,
"all_pass": all(r["ok"] for r in results) and not real_console and not page_errors}
(ROOT / "demo-smoke-result.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1), encoding="utf-8")
print("\n== SUMMARY ==")
for r in results:
print(("PASS " if r["ok"] else "FAIL ") + r["step"] + (" | " + r["note"] if r["note"] else ""))
print("console_errors:", len(console_errors), "(exempt_403:", len(expected_403), ") page_errors:", len(page_errors))
print("ALL_PASS:", summary["all_pass"])