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.
278 lines
17 KiB
278 lines
17 KiB
# -*- coding: utf-8 -*-
|
|
"""a7-heavy · demo 浏览器级 E2E(票 05:补码三域冒烟,Playwright chromium headless)
|
|
被测: .scratch/a7-heavy/demo/index.html?api=http://localhost:8080(ts= 注入脚本侧时间戳,两侧一致)
|
|
布局: 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"])
|
|
|