# -*- 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// 路径变量、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// 路径变量 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"])