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.
 
 
 
 
 

346 lines
18 KiB

# -*- coding: utf-8 -*-
"""a7-parity · demo 浏览器级 E2E(Playwright, chromium headless)
被测: .scratch/a7-parity/demo/index.html?api=http://localhost:8080
布局: 12 tab —— A7 八页细分(用户管理/角色管理/权限点管理/方案推送规则/项目查重规则/项目规则/线索池/字典种子验证)
+ dict-alignment 字典四页原样回归(分组管理/字典项管理/两级树/引用计数联测)
覆盖: 票07 用户/角色/权限点(统计出参/移交预览·自移交61019·执行返还/批量增量分配/copy停用草稿/
enable 61021/内置保护/references/禁删两段61009+61020) + 票08 推送规则状态机(D-01无步骤④发布/
64029/64030/64027) + 票09 查重规则(64031两路/引用计数草稿入账/草稿引用发布拦64030/64033) +
票10 项目规则(current聚合/64036)·线索池(三类成员/导入导出) + 票11 字典种子 + 字典四页移植回归预加载
附带断言: 网络层零 PUT/DELETE、零 /api/<module>/<digits>/ 路径变量、console/page error 零
清理: 演示角色/权限点/规则草稿/公海池 API 正门删除;BUDDY 角色还原;移交一来一回净零
"""
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
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():
"""中断轮次残留清理(幂等重跑保障):演示角色/权限点/规则草稿/公海池 + BUDDY 角色还原"""
try: tok = token_of()
except Exception as ex: print("pre_clean token fail:", ex); return
removed = {"roles": 0, "resources": 0, "push_drafts": 0, "dedup_drafts": 0, "pools": 0, "buddy_restored": False}
# BUDDY 角色还原为空(演示角色若残留会卡角色删除守卫)
post(f"{API}/api/system/users/assign-roles", {"userId": BUDDY, "roleIds": ""}, tok)
removed["buddy_restored"] = True
# 演示角色(含资源演示角色 RD;先删 RD 级联清资源引用,资源点才删得掉)
for kw in ("e2d-演示角色", "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
# 演示权限点(树里扫 e2d-演示权限点)
lst = get(f"{API}/api/resources/list", tok)
def walk(ns):
for n in ns or []:
if str(n.get("name", "")).startswith("e2d-演示权限点"):
post(f"{API}/api/resources/disable", {"id": str(n["id"])}, tok)
if post(f"{API}/api/resources/delete", {"id": str(n["id"])}, tok).get("code") == 0:
removed["resources"] += 1
walk(n.get("children"))
walk(lst.get("data") or [])
# 规则草稿(e2d- 前缀仅草稿可删;已发布转停用行留存可辨)
for mod in ("opp-push-rule", "opp-dedup-rule"):
pg = post(f"{API}/api/rule/{mod}/page", {"current": 1, "size": 100, "ruleName": "e2d-"}, tok)
for r in (pg.get("data") or {}).get("content", []):
if str(r.get("status")) == "1" and post(f"{API}/api/rule/{mod}/delete", {"id": str(r["id"])}, tok).get("code") == 0:
removed[("push_drafts" if "push" in mod else "dedup_drafts")] += 1
# 公海池
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 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 中未被公海池占用的(demo 演示池取 free[0],xlsx 用 free[1],天然错开 64003)"""
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 行合法数据(部门=空闲部门第二顺位)"""
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"_pool_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. 登录(合并 init:同时预载用户列表与字典分组/下拉)
page.click("button:has-text('uid 登录')")
page.wait_for_selector(".toast-item:has-text('登录成功')", timeout=15000)
step("登录 debug/token + /auth/me", True)
# 2. 字典四页移植回归:分组列表预加载
page.wait_for_selector("#gBody tr td:not(.empty)", state="attached", timeout=15000)
step("字典分组列表预加载 dict/group/page(移植回归)", page.locator("#gBody tr").count() > 0)
# 3. 字典项列表预加载
page.click("nav.tabs button[data-tab='ditem']")
page.click("#tab-ditem .viewbar button:has-text('查询')")
page.wait_for_selector("#iBody tr td:not(.empty)", timeout=15000)
step("字典项列表预加载 dict/item/page(移植回归)", page.locator("#iBody tr").count() > 0)
# 4. 用户列表加载
page.wait_for_selector("#uBody tr td:not(.empty)", state="attached", timeout=15000)
rows = page.locator("#uBody tr").count()
step("用户分页 users/page", rows > 0, f"rows={rows}")
# 5. 用户统计出参三时间戳
is_ok, text = run_step(page, "user", btn="#tab-user .viewbar button:has-text('用户统计')", outsel="#uStatsOut")
step("用户统计出参(三时间戳字段在位)", is_ok and "lastTransferTime=" in text, text[:90].replace("\n", " "))
page.screenshot(path=str(SHOTS / "01-user-stats.png"))
# 6. 移交联测:预览
is_ok, text = run_step(page, "user", "移交预览(admin 名下各域计数)", outsel="#uTrOut")
step("移交预览 transfer-preview", is_ok and "移交预览就绪" in text, text[:80].replace("\n", " "))
# 7. 自移交 61019
is_ok, text = run_step(page, "user", "自移交拦截", outsel="#uTrOut")
step("自移交拦截 61019", is_ok and "61019" in text, text[:80])
# 8. 移交执行 admin→BUDDY(全域清零)
is_ok, text = run_step(page, "user", "移交执行", outsel="#uTrOut", timeout=60000)
step("移交执行 admin→BUDDY(清零断言)", is_ok and "清零" in text, text[:90].replace("\n", " "))
page.screenshot(path=str(SHOTS / "02-transfer.png"))
# 9. 移交返还 BUDDY→admin(计数还原)
is_ok, text = run_step(page, "user", "移交返还", outsel="#uTrOut", timeout=60000)
step("移交返还(BUDDY 净零 + admin 保底)", is_ok and "返还成功" in text, text[:90].replace("\n", " "))
# 10. stats 时间戳
is_ok, text = run_step(page, "user", btn="#tab-user button:has-text('lastTransferTime 非空')", outsel="#uTrOut")
step("stats.lastTransferTime 非空", is_ok and "出参时间戳到位" in text, text[:90].replace("\n", " "))
# ===== Tab 角色管理 =====
is_ok, text = run_step(page, "role", "准备演示角色")
step("演示角色就绪", is_ok and "就绪" in text, text[:80])
is_ok, text = run_step(page, "role", "角色统计(四计数)")
step("roles/stats 四计数", is_ok and "角色统计" in text, text[:80])
is_ok, text = run_step(page, "role", "批量增量分配")
step("assign-roles-batch 增量分配", is_ok and "生效" in text, text[:80])
page.screenshot(path=str(SHOTS / "03-role-batch.png"))
is_ok, text = run_step(page, "role", "复制演示角色")
step("roles/copy 停用草稿 _copy_", is_ok and "_copy_" in text, text[:90])
is_ok, text = run_step(page, "role", "启用无授权副本")
step("roles/enable 校验 61021", is_ok and "61021" in text, text[:80])
is_ok, text = run_step(page, "role", "停用内置管理员角色")
step("内置角色禁停保护", is_ok and "保护生效" in text, text[:80])
is_ok, text = run_step(page, "role", "恢复 BUDDY 角色")
step("BUDDY 角色还原 + 删演示角色", is_ok and "已还原" in text, text[:80])
# ===== Tab 权限点管理 =====
is_ok, text = run_step(page, "res", "准备演示权限点")
step("演示权限点 + 禁删链路就绪", is_ok and "就绪" in text, text[:90])
is_ok, text = run_step(page, "res", "商机管理目录引用清单")
step("resources/references 非空", is_ok and "引用清单非空" in text, text[:80])
is_ok, text = run_step(page, "res", "演示点停用")
step("resources 停用→启用 roundtrip", is_ok and "roundtrip 成功" in text, text[:80])
is_ok, text = run_step(page, "res", "删除两段拦截")
step("resources 禁删两段 61009+61020", is_ok and "两段生效" in text, text[:100].replace("\n", " "))
page.screenshot(path=str(SHOTS / "04-resource-blocked.png"))
is_ok, text = run_step(page, "res", "清理(删 RD 级联清引用")
step("演示权限点清理", is_ok and "删除" in text, text[:80])
# ===== Tab 方案推送规则 =====
is_ok, text = run_step(page, "push", "新建 R1 草稿")
step("推送 R1 草稿(仅步骤①,嵌套绑定)", is_ok and "就绪" in text, text[:90])
is_ok, text = run_step(page, "push", "发布 R1", timeout=40000)
step("D-01 无步骤④发布(回归)", is_ok and "修复验证" in text, text[:80])
page.screenshot(path=str(SHOTS / "05-push-d01.png"))
is_ok, text = run_step(page, "push", "新建 R2 完整 4 步骤草稿")
step("推送 R2 草稿 + 详情回显", is_ok and "回显一致" in text, text[:110].replace("\n", " "))
is_ok, text = run_step(page, "push", "发布 R2")
step("推送 R2 发布 + versions", is_ok and "发布成功" in text, text[:90])
is_ok, text = run_step(page, "push", "同名发布中唯一")
step("推送 64029 同名发布中唯一", is_ok and "64029" in text, text[:80])
is_ok, text = run_step(page, "push", "创建方式非法")
step("推送 64030 创建方式值域", is_ok and "64030" in text, text[:80])
is_ok, text = run_step(page, "push", "复制 R2")
step("推送 copy 新编码 V1.0 草稿", is_ok and "复制成功" in text, text[:90])
is_ok, text = run_step(page, "push", "停用 R2")
step("推送 disable + 64027 仅草稿可删", is_ok and "64027" in text, text[:90])
page.screenshot(path=str(SHOTS / "06-push-disabled.png"))
is_ok, text = run_step(page, "push", "清理草稿")
step("推送草稿清理", is_ok and "清理" in text, text[:60])
# ===== Tab 项目查重规则 =====
is_ok, text = run_step(page, "dedup", "新建 D1")
step("查重 D1 发布 + referenceCount 出参", is_ok and "发布成功" in text, text[:100].replace("\n", " "))
is_ok, text = run_step(page, "dedup", "属地档缺适用对象")
step("查重 64031 属地缺对象", is_ok and "64031" in text, text[:80])
is_ok, text = run_step(page, "dedup", "属地档设默认")
step("查重 64031 仅全部商机可设默认", is_ok and "64031" in text, text[:80])
is_ok, text = run_step(page, "dedup", "推送规则 R5 草稿引用 D1")
step("引用计数草稿引用入账", is_ok and "入账" in text, text[:110].replace("\n", " "))
page.screenshot(path=str(SHOTS / "07-dedup-refcount.png"))
is_ok, text = run_step(page, "dedup", "引用草稿 D2 发布 R6")
step("草稿不可被正式引用 64030", is_ok and "64030" in text, text[:90])
is_ok, text = run_step(page, "dedup", "复制→删草稿 / 停用 D1")
step("查重状态机 copy/删/停用/64033", is_ok and "64033" in text, text[:100].replace("\n", " "))
is_ok, text = run_step(page, "dedup", "清理(删 R5/R6/D2")
step("查重草稿清理", is_ok and "清理" in text, text[:70])
# ===== Tab 项目规则 =====
is_ok, text = run_step(page, "projrule", "读当前配置")
step("项目规则 current 5 区块", is_ok and "聚合回显" in text, text[:100])
is_ok, text = run_step(page, "projrule", "保存阶段别名")
step("项目规则 分区保存 + 回显", is_ok and "回显" in text, text[:90])
is_ok, text = run_step(page, "projrule", "标底比例越界 150")
step("项目规则 64036 比例校验", is_ok and "64036" in text, text[:80])
# ===== Tab 线索池 =====
page.click("nav.tabs button[data-tab='pool']")
_tok = token_of()
_free = free_depts(_tok)
assert len(_free) >= 2, f"空闲部门不足 2 个: {_free}"
xlsx = make_import_xlsx(TS, _free[1])
page.set_input_files("#poolImportFile", str(xlsx))
is_ok, text = run_step(page, "pool", "建演示池")
step("线索池建池(三类异人成员提交)", is_ok and "就绪" in text, text[:90])
is_ok, text = run_step(page, "pool", "详情回填断言")
step("线索池三类成员回填", is_ok and "回填一致" in text, text[:90])
page.screenshot(path=str(SHOTS / "08-pool-members.png"))
is_ok, text = run_step(page, "pool", "导出")
step("线索池导出 xlsx(ZIP 魔数)", is_ok and "ZIP 魔数" in text, text[:70])
is_ok, text = run_step(page, "pool", "导入上方文件", timeout=40000)
step("线索池导入 BatchResult", is_ok and "导入成功" in text, text[:90])
is_ok, text = run_step(page, "pool", "列表断言")
step("线索池列表可见(演示+导入)", is_ok and "均在" in text, text[:80])
# ===== Tab 字典种子验证 =====
is_ok, text = run_step(page, "seed", "校验种子可见性")
step("字典种子 opp_stage 06/07 + opportunity_status", is_ok and "种子在位" in text, text[:110].replace("\n", " "))
page.screenshot(path=str(SHOTS / "09-dict-seed.png"))
is_ok, text = run_step(page, "pool", "清理(删演示池/导入池)")
step("线索池清理", is_ok and "清理" in text, text[:60])
# 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)
summary = {"ts": TS, "console_errors": console_errors, "page_errors": page_errors,
"results": results,
"all_pass": all(r["ok"] for r in results) and not console_errors 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), "page_errors:", len(page_errors))
print("ALL_PASS:", summary["all_pass"])