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.
 
 
 
 
 
 

349 lines
20 KiB

# -*- coding: utf-8 -*-
"""customer-e2e-r2 票 05 · demo Playwright 扩展冒烟(chromium headless)
被测: .scratch/customer-e2e/demo/index.html?api=http://localhost:8080
基线: demo_smoke10.py 11 步(登录/列表/汇总卡/新建/编辑/详情页签/成员/导入/交割/网络层)
新增: 战略协议 CRUD(五端点+快照同步) / 查重设置保存回读+恢复 / F6 批量工商(NO_HIT) / F8 duplicateStrategy=OVERWRITE 回显
附带断言: 网络层零 PUT/DELETE、零 /api/customer/<digits>/、console/page error 零
清理: dedup 恢复原值(无条件兜底) + 协议清扫 + e2c-r2demo-浏览器* archive 正门
"""
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(r"e:\code\crm-backend-matt\.scratch\customer-e2e")
SHOTS = ROOT / "shots-r2"; SHOTS.mkdir(exist_ok=True)
DEMO = ROOT / "demo" / "index.html"
XLSX = ROOT / "_import-heavy.xlsx"
ADMIN = "739564171091247104"
BUDDY = "744842318024015872"
FULL_ID = "750844477165273088" # e2c-全字段-科技(有联系人)
MEMBER_ID = "750844483406397440" # e2c-成员-样例
TS = time.strftime("%H%M%S")
NEW_NAME = f"e2c-r2demo-浏览器-{TS}"
AG_AMOUNT = "300万-r2"
import urllib.request, urllib.parse
def post(url, data, token=None):
req = urllib.request.Request(url, data=urllib.parse.urlencode(data).encode(), 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=15) as r:
return json.loads(r.read().decode())
def get_json(url, token=None):
req = urllib.request.Request(url)
if token: req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read().decode())
tok = get_json(f"http://localhost:8080/api/auth/debug/token?userId={ADMIN}")["data"]
# 查重设置为全局单例:起测前抓原值,收尾无条件恢复(UI 内另做保存回读演练)
dedup_orig = get_json("http://localhost:8080/api/rule/customer/dedup", tok).get("data") or {}
print("dedup 原值:", json.dumps(dedup_orig, ensure_ascii=False))
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 ""))
console_errors, page_errors, api_urls = [], [], []
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/customer" in u or "/api/rule" in u:
api_urls.append(f"{r.request.method} {u}")
page.on("response", on_resp)
# 0. 打开 demo(?api= 注入本地后端)
page.goto(DEMO.as_uri() + "?api=http://localhost:8080")
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SHOTS / "00-open.png"))
# 1. 登录(uid 选择器默认 admin)
page.click("button:has-text('uid 登录')")
page.wait_for_selector(".toast-item:has-text('登录成功')")
step("登录 debug/token + /auth/me", True)
# 2. 列表(新契约 workspace 表单字段)
page.wait_for_selector("#listBody tr td:not(.empty)", timeout=15000)
rows = page.locator("#listBody tr").count()
info = page.text_content("#pageInfo").strip()
step("列表 workspace/page", rows > 0 and "" in info, f"rows={rows}, {info}")
# 3. 汇总卡
page.wait_for_selector("#summaryBox .card", timeout=15000)
cards = page.locator("#summaryBox .card").count()
step("汇总卡 workspace/board/summary", cards > 0, f"cards={cards}")
# 4. 新建(/create,含 L2 相似命中 confirmSimilar 双分支)
page.click("button:has-text('+ 新建客户')")
page.wait_for_selector("#cName")
page.fill("#cName", NEW_NAME)
page.fill("#cType", "customer_type_01")
page.fill("#cProv", "440000"); page.fill("#cCity", "440100"); page.fill("#cDist", "440103")
page.fill("#cInd", "gov")
page.fill("#cOwner", ADMIN)
page.click(".dfoot button.ok")
confirm_needed = False
try:
page.wait_for_selector(".toast-item:has-text('创建成功')", timeout=6000)
except Exception:
confirm_needed = True
page.wait_for_selector("#cOut .simbox", timeout=8000)
page.click("#cOut button:has-text('确认创建')")
page.wait_for_selector(".toast-item:has-text('创建成功')", timeout=15000)
toast = page.text_content(".toast-item:has-text('创建成功')").strip()
m = re.search(r"创建成功 id=(\d+)", toast)
new_id = m.group(1) if m else ""
page.wait_for_selector("dialog#dlg:not([open])", state="attached")
step("新建客户 create", bool(new_id), f"id={new_id}, confirmSimilar={'' if confirm_needed else ''}")
page.screenshot(path=str(SHOTS / "01-created.png"))
# 5. 编辑(detail?id= → edit?id= 带 version/isBizNegotiated/isChild)
page.click(f"#listBody td.row:has-text('{NEW_NAME}')")
page.wait_for_selector("#headBox b", timeout=15000)
page.click("#headBox button:has-text('编辑')")
page.wait_for_selector("#eName")
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('保存成功')", timeout=15000)
step("编辑客户 detail?id= + edit?id=(CAS)", True, f"customer={NEW_NAME}")
page.screenshot(path=str(SHOTS / "02-edited.png"))
# 6. 详情页签(seed 全字段客户:六页签含新增 agreements 全点一遍)
page.reload(); page.wait_for_load_state("networkidle")
page.click("nav.tabs button[data-tab='detail']")
page.fill("#didInput", FULL_ID)
page.click("button:has-text('打开')")
page.wait_for_selector("#headBox b", timeout=15000)
sub_ok, sub_note = True, []
for s in ["contacts", "follow", "opps", "agreements", "members", "oplog"]:
page.click(f"#detailPanel .subtabs button[data-s='{s}']")
page.wait_for_timeout(700)
if page.locator("#subBox .errbox").count():
sub_ok = False
sub_note.append(page.text_content("#subBox .errbox").strip()[:80])
step("详情页签六页签无错误(含 agreements)", sub_ok, "; ".join(sub_note))
page.click("#detailPanel .subtabs button[data-s='contacts']")
page.wait_for_selector("#subBox table", timeout=15000)
contact_rows = page.locator("#subBox table tbody tr").count()
step("联系人列表 contacts?customerId=", contact_rows > 0, f"rows={contact_rows}")
page.screenshot(path=str(SHOTS / "03-detail-tabs.png"))
# 7. 成员加/移除(member/add 数组表单 → member/remove)
page.reload(); page.wait_for_load_state("networkidle")
page.click("nav.tabs button[data-tab='detail']")
page.fill("#didInput", MEMBER_ID)
page.click("button:has-text('打开')")
page.wait_for_selector("#headBox b", timeout=15000)
page.click("#detailPanel .subtabs button[data-s='members']")
page.wait_for_timeout(700)
if page.locator("#subBox tr:has-text('曾偲青') button:has-text('移除')").count():
page.click("#subBox tr:has-text('曾偲青') button:has-text('移除')")
page.wait_for_selector(".toast-item:has-text('已移除')", timeout=10000)
page.wait_for_timeout(700)
before = page.locator("#subBox table tbody tr").count()
page.click("#subBox button:has-text('+ 添加协同人')")
page.wait_for_selector("#mIds")
page.fill("#mIds", BUDDY)
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('成员已添加')", timeout=15000)
page.wait_for_selector("#subBox:has-text('曾偲青')", timeout=15000)
page.click("#subBox tr:has-text('曾偲青') button:has-text('移除')")
page.wait_for_selector(".toast-item:has-text('已移除')", timeout=15000)
page.wait_for_timeout(700)
step("成员加/移除 member/add+remove", True, f"rows {before}->{page.locator('#subBox table tbody tr').count()}")
page.screenshot(path=str(SHOTS / "04-members.png"))
# 8.【新入口·协议 CRUD】自建客户(owner=admin,与 BUDDY 归属解耦)五端点 + 快照同步
page.reload(); page.wait_for_load_state("networkidle")
page.click("nav.tabs button[data-tab='detail']")
page.fill("#didInput", new_id)
page.click("button:has-text('打开')")
page.wait_for_selector("#headBox b", timeout=15000)
page.click("#detailPanel .subtabs button[data-s='agreements']")
page.wait_for_selector("#subBox button:has-text('+ 新增协议')", timeout=15000)
page.click("#subBox button:has-text('+ 新增协议')")
page.wait_for_selector("dialog#dlg[open] #agLevel", timeout=10000)
page.select_option("#agLevel", "2")
page.fill("#agAmount", AG_AMOUNT)
page.fill("#agRemark", "r2 UI 冒烟协议")
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('协议已保存')", timeout=15000)
page.wait_for_selector(f"#subBox tr:has-text('{AG_AMOUNT}')", timeout=15000)
ags = page.evaluate("window._agreements || []")
aid = next((str(a["id"]) for a in ags if str(a.get("amount") or "") == AG_AMOUNT), "")
step("协议新增 agreement/create", bool(aid), f"agreementId={aid}, level=2, amount={AG_AMOUNT}")
page.click("#detailPanel .subtabs button[data-s='info']")
page.wait_for_selector("#subBox .grid", timeout=15000)
snap = page.evaluate("(()=>{const kv=[...document.querySelectorAll('#subBox .kv')].find(x=>x.querySelector('b')&&x.querySelector('b').textContent==='战略协议');return kv?kv.querySelector('span').textContent:''})()")
step("客户快照同步 strategic_agreement_level", snap in ("二级", "2"), f"info.grid 战略协议={snap!r}")
page.screenshot(path=str(SHOTS / "05-agreement-created.png"))
page.click("#detailPanel .subtabs button[data-s='agreements']")
page.wait_for_selector(f"#subBox tr:has-text('{AG_AMOUNT}') button:has-text('编辑')", timeout=15000)
page.click(f"#subBox tr:has-text('{AG_AMOUNT}') button:has-text('编辑')")
page.wait_for_selector("dialog#dlg[open] #agLevel", timeout=10000)
prefill_ok = page.input_value("#agLevel") == "2"
page.select_option("#agLevel", "3")
page.click(".dfoot button.ok")
page.wait_for_selector(".toast-item:has-text('协议已保存')", timeout=15000)
page.wait_for_selector(f"#subBox tr:has-text('{AG_AMOUNT}'):has-text('三级')", timeout=15000)
step("协议编辑 agreement/edit(回显+升级三级)", prefill_ok, f"prefill level=2 -> 3, id={aid}")
page.click(f"#subBox tr:has-text('{AG_AMOUNT}') button:has-text('详情')")
page.wait_for_selector("dialog#dlg[open]:has-text('协议详情')", timeout=10000)
detail_ok = "三级" in (page.text_content("dialog#dlg") or "")
page.keyboard.press("Escape")
page.wait_for_timeout(400)
step("协议详情 agreement/detail?id=", detail_ok, f"id={aid}, 等级=三级")
page.once("dialog", lambda d: d.accept()) # 原生 confirm('确认删除该战略协议?')
page.click(f"#subBox tr:has-text('{AG_AMOUNT}') button:has-text('删除')")
page.wait_for_selector(".toast-item:has-text('已删除')", timeout=15000)
page.wait_for_selector("#subBox td.empty:has-text('无数据')", timeout=15000)
step("协议删除 agreement/delete(confirm 流+列表空)", True, f"id={aid}, 删空后快照回 NULL")
page.screenshot(path=str(SHOTS / "06-agreement-deleted.png"))
# 9.【新入口·查重设置】保存 → 重载回读 → 恢复原值(全局单例)
page.click("nav.tabs button[data-tab='settings']")
page.wait_for_selector(".toast-item:has-text('查重设置已加载')", timeout=15000)
orig_ui = {k: page.input_value(k) for k in ["#dpMaster", "#dpName", "#dpMode", "#dpThreshold", "#dpPhone"]}
page.select_option("#dpMaster", "1"); page.select_option("#dpName", "1")
page.select_option("#dpMode", "1")
page.fill("#dpThreshold", "66")
page.select_option("#dpPhone", "0")
page.click("button[onclick='saveDedup()']")
page.wait_for_selector(".toast-item:has-text('查重设置已保存')", timeout=15000)
page.click("nav.tabs button[data-tab='settings']") # 重进触发 loadDedup 回读
page.wait_for_selector(".toast-item:has-text('查重设置已加载')", timeout=15000)
page.wait_for_timeout(900) # 防 toast 残留竞态:等 GET 回来覆盖控件值
rb = {k: page.input_value(k) for k in ["#dpMaster", "#dpName", "#dpMode", "#dpThreshold", "#dpPhone"]}
rb_ok = rb["#dpMaster"] == "1" and rb["#dpMode"] == "1" and rb["#dpThreshold"] == "66" and rb["#dpPhone"] == "0"
step("查重设置 save→回读(dedup/save 全量覆盖)", rb_ok, f"改后={rb}")
# 恢复原值(UI 面)
page.select_option("#dpMaster", orig_ui["#dpMaster"]); page.select_option("#dpName", orig_ui["#dpName"])
page.select_option("#dpMode", orig_ui["#dpMode"]); page.fill("#dpThreshold", orig_ui["#dpThreshold"])
page.select_option("#dpPhone", orig_ui["#dpPhone"])
page.click("button[onclick='saveDedup()']")
page.wait_for_selector(".toast-item:has-text('查重设置已保存')", timeout=15000)
page.click("nav.tabs button[data-tab='settings']")
page.wait_for_selector(".toast-item:has-text('查重设置已加载')", timeout=15000)
page.wait_for_timeout(900)
back = {k: page.input_value(k) for k in ["#dpMaster", "#dpName", "#dpMode", "#dpThreshold", "#dpPhone"]}
restore_ok = back == orig_ui
step("查重设置恢复原值", restore_ok, f"原值={orig_ui} 恢复后={back}")
page.screenshot(path=str(SHOTS / "07-dedup-settings.png"))
# 10.【新入口·F6 批量工商】勾选自建客户 → 批量更新 → NO_HIT 徽标(stub 如实呈现)
page.click("nav.tabs button[data-tab='list']")
page.wait_for_timeout(600)
page.click(f"#listBody tr:has-text('{NEW_NAME}') input[type='checkbox']")
page.wait_for_timeout(300)
pick = page.text_content("#pickHint").strip()
page.click("button[onclick='batchCompanyUpdate()']")
page.wait_for_selector("dialog#dlg[open]:has-text('批量工商更新结果')", timeout=15000)
page.wait_for_timeout(600)
cu_row = page.locator(f"dialog#dlg tbody tr:has-text('{NEW_NAME}')").count()
cu_hit = page.locator(f"dialog#dlg tbody tr:has-text('{NEW_NAME}'):has-text('未命中')").count()
page.keyboard.press("Escape")
page.wait_for_timeout(400)
step("F6 批量工商 company-lookup/batch-update(stub 恒 NO_HIT)",
"已勾选 1 条" in pick and cu_row == 1 and cu_hit == 1, f"{pick}; 结果行={cu_row}, NO_HIT 徽标={cu_hit}")
page.click(f"#listBody tr:has-text('{NEW_NAME}') input[type='checkbox']") # 反勾还原
page.screenshot(path=str(SHOTS / "08-f6-batch.png"))
# 11.【新入口·F8 导入策略】duplicateStrategy=OVERWRITE 响应回显
page.click("nav.tabs button[data-tab='import']")
page.wait_for_timeout(800)
page.select_option("#impStrategy", "OVERWRITE")
page.set_input_files("#impFile", str(XLSX))
page.click("button:has-text('上传预检')")
page.wait_for_selector(".toast-item:has-text('预检完成')", timeout=30000)
toast = page.text_content(".toast-item:has-text('预检完成')").strip()
step("导入 upload 预检 + duplicateStrategy=OVERWRITE 回显", "策略=OVERWRITE" in toast, toast[:110])
page.screenshot(path=str(SHOTS / "09-f8-import.png"))
# 12. 交割(page 列表 + preview,只读不 initiate)
page.click("nav.tabs button[data-tab='transfer']")
page.wait_for_timeout(900)
tr_rows = page.locator("#trBody tr").count()
has_err = page.locator("#trBody .errbox").count()
page.click("button:has-text('预览名下待交接客户')")
page.wait_for_selector("dialog#dlg[open]", timeout=15000)
page.wait_for_timeout(600)
prev_fail = page.locator(".toast-item:has-text('预览失败')").count()
prev_rows = page.locator("dialog#dlg table tbody tr").count()
page.keyboard.press("Escape")
step("交割 transfer/page + preview(不 initiate,规避 BUDDY 圈定副作用)",
tr_rows >= 0 and has_err == 0 and prev_fail == 0, f"交接单行={tr_rows}, 预览行={prev_rows}")
page.screenshot(path=str(SHOTS / "10-transfer.png"))
# 13. 网络层断言:新契约形态(零 PUT/DELETE、零 /api/customer/<digits>/ 路径变量)
old_style = [u for u in api_urls if re.search(r"/api/customer/\d+/", u)]
bad_method = [u for u in api_urls if u.startswith(("PUT ", "DELETE "))]
step("网络层零旧契约形态", not old_style and not bad_method,
f"old_style={old_style[:3]} bad_method={bad_method[:3]} (共{len(api_urls)}个请求)")
browser.close()
# ---- 正门清理:协议清扫 + dedup 无条件兜底恢复 + e2c-r2demo-浏览器* archive ----
cleaned_agreements, archived = [], []
def sweep_agreements(cid, token):
try:
lst = get_json(f"http://localhost:8080/api/customer/agreement/list?customerId={cid}", token).get("data") or []
rows = lst if isinstance(lst, list) else (lst.get("content") or [])
for a in rows:
try:
post(f"http://localhost:8080/api/customer/agreement/delete", {"id": a["id"]}, token)
cleaned_agreements.append(str(a["id"]))
except Exception as ex:
print("agreement delete fail", a.get("id"), ex)
except Exception as ex:
print("agreement list fail", cid, ex)
if new_id:
sweep_agreements(new_id, tok)
# dedup 兜底:无论 UI 恢复是否成功,把起测前原值 POST 回去
try:
post("http://localhost:8080/api/rule/customer/dedup/save", {
"masterEnabled": 1 if dedup_orig.get("masterEnabled") else 0,
"nameEnabled": 1 if dedup_orig.get("nameEnabled") else 0,
"nameMatchMode": dedup_orig.get("nameMatchMode") if dedup_orig.get("nameMatchMode") is not None else 2,
"similarityThreshold": dedup_orig.get("similarityThreshold") if dedup_orig.get("similarityThreshold") is not None else 80,
"phoneEnabled": 1 if dedup_orig.get("phoneEnabled") else 0,
}, tok)
now_dedup = get_json("http://localhost:8080/api/rule/customer/dedup", tok).get("data") or {}
dedup_restored = (bool(now_dedup.get("masterEnabled")) == bool(dedup_orig.get("masterEnabled"))
and str(now_dedup.get("similarityThreshold")) == str(dedup_orig.get("similarityThreshold")))
step("查重设置兜底恢复=原值(单例)", dedup_restored, f"orig={dedup_orig} now={now_dedup}")
except Exception as ex:
print("dedup restore fail:", ex)
# 客户归档(含历史孤儿)
found = post("http://localhost:8080/api/customer/workspace/page",
{"workspace": "mine", "keyword": "e2c-r2demo-浏览器", "archiveStatus": 1, "current": 1, "size": 50}, tok)
ids = [str(r["id"]) for r in (found.get("data") or {}).get("content", [])]
for cid in ids:
sweep_agreements(cid, tok)
try:
post("http://localhost:8080/api/customer/archive", {"id": cid}, tok)
archived.append(cid)
except Exception as ex:
print("archive fail", cid, ex)
print("cleanup agreements:", cleaned_agreements, "archived:", archived)
# 汇总
summary = {"ts": TS, "new_customer": NEW_NAME, "new_id": new_id if new_id else "",
"console_errors": console_errors, "page_errors": page_errors,
"dedup_orig": dedup_orig, "archived": archived, "cleaned_agreements": cleaned_agreements,
"results": results,
"all_pass": all(r["ok"] for r in results) and not console_errors and not page_errors}
(ROOT / "demo-smoke-r2-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"])