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.

388 lines
21 KiB

4 hours ago
# -*- coding: utf-8 -*-
"""dict-alignment · demo 浏览器级 E2E(Playwright, chromium headless)
被测: .scratch/dict-alignment/demo/index.html?api=http://localhost:8080
覆盖: 票03/04 拍板矩阵(键值必填63001 / 默认项禁停63013两路 / 键值被引用可改 / 换组放开+拦截63009 / 分组编辑状态可改)
+ 票07 ref_count 接线(新建入账换值随迁删除阻断)
+ 票04 新端点 GET /api/dict/item/tree 两级树
附带断言: 网络层零 PUT/DELETE /api/dict/<digits>/ 路径变量console/page error
清理: 演示分组/字典项 API 正门删除演示客户归档
"""
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"
TS = time.strftime("%H%M%S")
GA_CODE = f"e2d_group_a_{TS}"
GA_NAME = f"e2d-演示分组A-{TS}"
GB_CODE = f"e2d_group_b_{TS}"
GB_NAME = f"e2d-演示分组B-{TS}"
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 api_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)
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode())
def _del_item(it, tok):
"""删字典项:被引用(残留归档客户)时普通删除 63009 → force-delete 兜底"""
try: api_post(f"{API}/api/dict/item/delete", {"id": str(it["id"])}, tok); return True
except Exception:
try: api_post(f"{API}/api/dict/item/force-delete?id=" + str(it["id"]), None, tok); return True
except Exception: return False
def pre_clean():
"""中断轮次残留清理(幂等重跑保障):上轮死在中途时演示分组/项/客户会污染本轮
实例残留分组B 里有同名 value 的项2 本轮换组撞 63007 保存失败无 toast"""
try: tok = json.loads(urllib.request.urlopen(f"{API}/api/auth/debug/token?userId={ADMIN}", timeout=10).read().decode())["data"]
except Exception as ex: print("pre_clean token fail:", ex); return
removed = {"items": 0, "groups": 0, "customers": 0}
for cur in (1, 2, 3, 4, 5):
pg = api_post(f"{API}/api/dict/item/page", {"current": cur, "size": 100}, tok)
for it in (pg.get("data") or {}).get("content", []):
if str(it.get("name", "")).startswith("e2d-") or str(it.get("code", "")).startswith("e2d_"):
if _del_item(it, tok): removed["items"] += 1
if cur >= int((pg.get("data") or {}).get("pages", 1) or 1): break
for kw in ("e2d_group_", "e2d_probe_"):
gp = api_post(f"{API}/api/dict/group/page", {"current": 1, "size": 50, "keyword": kw}, tok)
for g in (gp.get("data") or {}).get("content", []):
if not str(g.get("code", "")).startswith(("e2d_group_", "e2d_probe_")): continue
ip = api_post(f"{API}/api/dict/item/page", {"current": 1, "size": 200, "groupId": str(g["id"])}, tok)
for it in (ip.get("data") or {}).get("content", []): _del_item(it, tok)
try: api_post(f"{API}/api/dict/group/delete", {"id": str(g["id"])}, tok); removed["groups"] += 1
except Exception: pass
for ws in ("mine", "pool"):
found = api_post(f"{API}/api/customer/workspace/page", {"workspace": ws, "keyword": "e2d-引用联测", "current": 1, "size": 50}, tok)
for c in (found.get("data") or {}).get("content", []):
cid = str(c["id"])
try:
try: api_post(f"{API}/api/customer/claim?id=" + cid, {}, tok)
except Exception: pass
api_post(f"{API}/api/customer/archive", {"id": cid}, tok); removed["customers"] += 1
except Exception: pass
print("pre_clean:", removed)
pre_clean()
console_errors, page_errors, api_urls = [], [], []
def clear_toasts(page):
"""清空旧 toast,防止 6s 存活期内的新 toast 误命中等待条件"""
page.evaluate("document.getElementById('toast').innerHTML = ''")
def opt_val(page, sel, pat):
"""按 label 正则找 select 里 option 的 value(Playwright select_option 的 label 参数不支持 regex)"""
pairs = page.eval_on_selector_all(sel + " option", "os => os.map(o => [o.value, o.textContent])")
for v, t in pairs:
if re.search(pat, t): return v
return ""
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)))
page.on("dialog", lambda d: d.accept()) # demo 的 confirm(删除/换组)一律接受
def on_resp(r):
u = r.url
if "/api/dict" in u or "/api/customer" in u or "/api/auth" in u:
api_urls.append(f"{r.request.method} {u}")
page.on("response", on_resp)
# 0. 打开 demo
page.goto(DEMO.as_uri() + "?api=" + API)
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SHOTS / "00-open.png"))
# 1. 登录
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)", timeout=15000)
g_rows = page.locator("#gBody tr").count()
step("分组分页 group/page", g_rows > 0, f"rows={g_rows}")
# 3. 新建分组A(编码必填·全局唯一)
page.click("button:has-text('+ 新建分组')")
page.wait_for_selector("#fgName")
page.fill("#fgName", GA_NAME)
page.fill("#fgCode", GA_CODE)
page.fill("#fgDesc", "dict-alignment e2e 演示分组A")
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('分组保存成功')", timeout=15000)
page.fill("#gKeyword", GA_CODE)
page.click("#tab-group .viewbar button:has-text('查询')")
page.wait_for_selector(f"#gBody tr:has-text('{GA_NAME}')", timeout=15000)
ga_row = page.locator(f"#gBody tr:has-text('{GA_NAME}')")
step("新建分组A saveOrUpdate", ga_row.count() == 1, f"{GA_CODE}")
page.screenshot(path=str(SHOTS / "01-group-created.png"))
# 4. 编辑分组A改状态为停用(拍板5:编辑弹窗状态可改,保存即生效)
ga_id = re.search(r"dlgGroup\('(\d+)'",
ga_row.locator("td.ops button:has-text('编辑')").get_attribute("onclick")).group(1)
page.click(f"#gBody button[onclick*=\"dlgGroup('{ga_id}')\"]")
page.wait_for_selector("#fgStatus")
page.select_option("#fgStatus", "0")
clear_toasts(page)
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('分组保存成功')", timeout=15000)
# 列表刷新是异步的:状态等待停用 badge 出现(冷启动时 600ms 硬等待会竞态挂掉)
page.wait_for_selector(f"#gBody tr:has-text('{GA_NAME}') .badge.b4:has-text('停用')", timeout=10000)
stopped = page.locator(f"#gBody tr:has-text('{GA_NAME}') .badge.b4:has-text('停用')").count()
step("分组编辑弹窗改状态(拍板5)", stopped == 1, f"停用badge={stopped}")
# 恢复启用(后续要在组内建项)
page.click(f"#gBody button[onclick*=\"groupStatus('{ga_id}','1')\"]")
page.wait_for_timeout(600)
# 5. 新建分组B(换组目标)
page.fill("#gKeyword", "")
page.click("button:has-text('+ 新建分组')")
page.wait_for_selector("#fgName")
page.fill("#fgName", GB_NAME)
page.fill("#fgCode", GB_CODE)
clear_toasts(page)
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('分组保存成功')", timeout=15000)
step("新建分组B(换组目标)", True, GB_CODE)
# 6. 字典项:新建空白键值 → 63001(拍板3 必填)
page.click("nav.tabs button[data-tab='item']")
page.wait_for_selector(f"#iGroup option[value='{ga_id}']", state="attached", timeout=15000)
page.select_option("#iGroup", ga_id)
page.wait_for_timeout(400)
page.click("button:has-text('+ 新建字典项')")
page.wait_for_selector("#fiName")
page.select_option("#fiGroup", ga_id) # 显式钉分组A,防下拉默认值漂移
page.fill("#fiName", "e2d-项1")
# 键值留空
page.click(".dfoot button.ok")
page.wait_for_selector("#fiOut .errbox", timeout=15000)
err1 = page.text_content("#fiOut .errbox")
ok1 = "63001" in err1 and "键值" in err1
step("键值必填拦截 63001(拍板3)", ok1, err1.strip()[:60])
page.screenshot(path=str(SHOTS / "02-value-required.png"))
# 7. 补键值保存 → 编码自动生成 dict_ 前缀(拍板3 兵底)
page.fill("#fiValue", "e2d-val-1")
clear_toasts(page)
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000)
page.wait_for_timeout(600)
row1 = page.locator(f"#iBody tr:has-text('e2d-项1')")
code1 = row1.locator("td").nth(2).text_content().strip()
auto_code = code1.startswith("dict_")
step("新建字典项编码自动生成 dict_(拍板3)", row1.count() == 1 and auto_code, f"code={code1}")
# 8. 新建项2(后续换组用)
page.click("button:has-text('+ 新建字典项')")
page.wait_for_selector("#fiName")
page.select_option("#fiGroup", ga_id) # 显式钉分组A
page.fill("#fiName", "e2d-项2")
page.fill("#fiValue", "e2d-val-2")
page.fill("#fiCode", f"e2d_item2_{TS}")
clear_toasts(page)
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000)
page.wait_for_timeout(600)
step("新建项2(自定义编码)", page.locator(f"#iBody tr:has-text('e2d-项2')").count() == 1)
# 9. 项1 设默认
item1_id = re.search(r"itemSetDefault\('(\d+)','(\d+)'",
row1.locator("td.ops button:has-text('设默认')").get_attribute("onclick")).group(2)
page.click(f'#iBody button[onclick*="itemSetDefault(\'{ga_id}\',\'{item1_id}"]')
page.wait_for_selector(".toast-item:has-text('已设为分组默认项')", timeout=15000)
page.wait_for_timeout(600)
has_default = page.locator(f"#iBody tr:has-text('e2d-项1') .badge.b5:has-text('默认')").count()
step("设默认项 set-default", has_default == 1, f"itemId={item1_id}")
# 10. 停用默认项(列表行操作路)→ 63013(拍板2 路1)
page.click(f'#iBody button[onclick*="itemStatus(\'{item1_id}\',\'0"]')
page.wait_for_selector(".toast-item:has-text('63013')", timeout=15000)
t1 = page.text_content(".toast-item:has-text('63013')")
step("默认项禁停 /status 路 63013(拍板2①)", True, t1.strip()[:70])
# 11. 编辑弹窗改停用(saveOrUpdate 路)→ 63013(拍板2 路2)
page.click(f"#iBody tr:has-text('e2d-项1') td.ops button:has-text('编辑')")
page.wait_for_selector("#fiStatus")
page.select_option("#fiStatus", "0")
page.click(".dfoot button.ok")
page.wait_for_selector("#fiOut .errbox", timeout=15000)
err2 = page.text_content("#fiOut .errbox")
step("默认项禁停 saveOrUpdate 路 63013(拍板2②)", "63013" in err2 and "默认项" in err2, err2.strip()[:70])
page.screenshot(path=str(SHOTS / "03-default-disable-blocked.png"))
page.click(".dfoot button:has-text('取消')")
# 12. 换组放开(拍板6):项2 → 分组B(未被引用)
page.click(f"#iBody tr:has-text('e2d-项2') td.ops button:has-text('编辑')")
page.wait_for_selector("#fiGroup")
page.select_option("#fiGroup", opt_val(page, "#fiGroup", re.escape(GB_NAME)))
clear_toasts(page)
page.click(".dfoot button.ok") # confirm 换组 → dialog handler 接受
page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000)
page.wait_for_timeout(800)
page.select_option("#iGroup", opt_val(page, "#iGroup", re.escape(GB_NAME)))
# 列表刷新是异步的:状态等待项2行出现(冷启动时 600ms 硬等待会竞态挂掉)
page.wait_for_selector("#iBody tr:has-text('e2d-项2')", timeout=10000)
moved = page.locator("#iBody tr:has-text('e2d-项2')").count()
step("未被引用换组放行 + 计数行随迁(拍板6①)", moved == 1, f"项2已出现在分组B列表 rows={moved}")
page.screenshot(path=str(SHOTS / "04-move-group-ok.png"))
# 13. 两级树(票04 新端点,industry 分组有二级)
page.click("nav.tabs button[data-tab='tree']")
page.wait_for_selector("#tGroup option", state="attached")
ind_label = page.locator("#tGroup option", has_text="行业").first.get_attribute("value")
if not ind_label:
# 兜底:找 code=industry 的选项
for i in range(page.locator("#tGroup option").count()):
o = page.locator("#tGroup option").nth(i)
if "industry" in (o.get_attribute("value") or "") or "行业" in o.text_content():
ind_label = o.get_attribute("value"); break
page.select_option("#tGroup", ind_label)
page.wait_for_selector("#treeBox .lv1", timeout=15000)
page.wait_for_timeout(800)
lv1 = page.locator("#treeBox .lv1").count()
lv2 = page.locator("#treeBox .lv2").count()
step("两级树 GET /api/dict/item/tree(票04增项)", lv1 > 0, f"一级={lv1}, 二级={lv2}")
page.screenshot(path=str(SHOTS / "05-tree.png"))
# 14. 引用计数联测:步骤0 准备演示项(customer_type 组)
page.click("nav.tabs button[data-tab='ref']")
page.evaluate("document.getElementById('refOut').innerHTML = ''")
page.click("button:has-text('准备演示字典项')")
page.wait_for_selector("#refOut .okbox:has-text('就绪')", timeout=20000)
step("联测步骤0 演示项幂等创建", True, page.text_content("#refOut .okbox").strip()[:80])
# 15. 步骤1 新建客户引用 → referenced=true(票07 入账)
page.evaluate("document.getElementById('refOut').innerHTML = ''")
page.click("button:has-text('新建客户引用此字典')")
page.wait_for_selector("#refOut .okbox, #refOut .errbox", timeout=30000)
out1 = page.text_content("#refOut")
ok_ref1 = "referenced=true" in out1 and "(接线生效)" in out1
step("联测步骤1 新建客户 ref_count 入账(票07)", ok_ref1, out1.strip()[:90])
page.screenshot(path=str(SHOTS / "06-ref-created.png"))
# 16. 步骤2 换引用 → a=false / b=true(票07 随迁)
page.evaluate("document.getElementById('refOut').innerHTML = ''")
page.click("button:has-text('编辑客户换引用到 e2d_type_b')")
page.wait_for_selector("#refOut .okbox, #refOut .errbox", timeout=30000)
out2 = page.text_content("#refOut")
ok_ref2 = "e2d_type_a.referenced=false" in out2 and "e2d_type_b.referenced=true" in out2
step("联测步骤2 换引用随迁(票07 applyFieldChange)", ok_ref2, out2.strip()[:110])
page.screenshot(path=str(SHOTS / "07-ref-swapped.png"))
# 17. 步骤3 删除被引用项 → 63009 阻断(票04/07 引用阻断生效)
page.evaluate("document.getElementById('refOut').innerHTML = ''")
page.click("button:has-text('尝试删除当前被引用项')")
page.wait_for_selector("#refOut .okbox, #refOut .errbox", timeout=20000)
out3 = page.text_content("#refOut")
ok_ref3 = "63009" in out3 and "阻断生效" in out3
step("联测步骤3 删除被引用项 63009 阻断", ok_ref3, out3.strip()[:90])
# 18. 键值被引用可改(拍板4):e2d_type_b 已被引用,改键值应放行
page.click("nav.tabs button[data-tab='item']")
page.wait_for_timeout(400)
page.select_option("#iGroup", "") # 清掉步骤12残留的分组B过滤
page.fill("#iKeyword", "e2d_type_b")
page.click("#tab-item .viewbar button:has-text('查询')")
page.wait_for_selector("#iBody tr:has-text('e2d-演示类型B')", timeout=15000)
page.click("#iBody tr:has-text('e2d-演示类型B') td.ops button:has-text('编辑')")
page.wait_for_selector("#fiValue")
page.fill("#fiValue", "e2d-val-b-已改")
clear_toasts(page)
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('字典项保存成功')", timeout=15000)
step("被引用键值仍可改(拍板4)", True, "e2d_type_b value→e2d-val-b-已改")
page.screenshot(path=str(SHOTS / "08-value-mutable.png"))
# 19. 被引用项换组拦截 63009(拍板6②)——e2d_type_b 被引用,换组应拦
page.click("#iBody tr:has-text('e2d-演示类型B') td.ops button:has-text('编辑')")
page.wait_for_selector("#fiGroup")
page.select_option("#fiGroup", opt_val(page, "#fiGroup", re.escape(GA_NAME)))
page.click(".dfoot button.ok") # confirm 接受
page.wait_for_selector("#fiOut .errbox", timeout=15000)
err4 = page.text_content("#fiOut .errbox")
ok4 = "63009" in err4 and ("已被引用" in err4 or "不可修改分组" in err4)
step("被引用换组拦截 63009(拍板6②)", ok4, err4.strip()[:70])
page.screenshot(path=str(SHOTS / "09-move-blocked.png"))
page.click(".dfoot button:has-text('取消')")
# 20. 网络层断言:零 PUT/DELETE、零 /api/dict/<digits>/ 路径变量
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/dict/\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()
# ---- API 清理:演示分组/项正门删除,演示客户归档 ----
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)
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode())
def get(url, token=None):
req = urllib.request.Request(url)
if token: req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read().decode())
cleanup = {"groups": [], "items": [], "customers": []}
try:
tok = json.loads(urllib.request.urlopen(f"{API}/api/auth/debug/token?userId={ADMIN}", timeout=10).read().decode())["data"]
# 归档演示客户(e2d-引用联测 前缀):创建后落公海(无 owner),mine/pool 两视图都搜,公海客户先领取再归档
for ws in ("mine", "pool"):
found = post(f"{API}/api/customer/workspace/page",
{"workspace": ws, "keyword": "e2d-引用联测", "current": 1, "size": 50}, tok)
for c in (found.get("data") or {}).get("content", []):
cid = str(c["id"])
if cid in cleanup["customers"]: continue
try:
try: post(f"{API}/api/customer/claim?id=" + cid, {}, tok)
except Exception: pass # 已领取/非公海时忽略,直接归档
post(f"{API}/api/customer/archive", {"id": cid}, tok)
cleanup["customers"].append(cid)
except Exception as ex: print("archive fail", cid, ex)
# 删演示分组B/A(组内项先删)
for gcode in (GB_CODE, GA_CODE):
gp = post(f"{API}/api/dict/group/page", {"current": 1, "size": 20, "keyword": gcode}, tok)
g = next((x for x in (gp.get("data") or {}).get("content", []) if x.get("code") == gcode), None)
if not g: continue
ip = post(f"{API}/api/dict/item/page", {"current": 1, "size": 100, "groupId": str(g["id"])}, tok)
for it in (ip.get("data") or {}).get("content", []):
try: post(f"{API}/api/dict/item/delete", {"id": str(it["id"])}, tok); cleanup["items"].append(it.get("code"))
except Exception as ex: print("item delete fail", it.get("code"), ex)
try: post(f"{API}/api/dict/group/delete", {"id": str(g["id"])}, tok); cleanup["groups"].append(gcode)
except Exception as ex: print("group delete fail", gcode, ex)
except Exception as ex:
print("cleanup error:", ex)
print("cleanup:", json.dumps(cleanup, ensure_ascii=False))
# ---- 汇总 ----
summary = {"ts": TS, "group_a": GA_CODE, "group_b": GB_CODE,
"console_errors": console_errors, "page_errors": page_errors,
"cleanup": cleanup, "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"])