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.

207 lines
11 KiB

19 hours ago
# -*- coding: utf-8 -*-
"""票 10 demo 契约同步 · 浏览器级主流程点通(Playwright, chromium headless)
被测: .scratch/customer-e2e/demo/index.html?api=http://localhost:8080
流程: 登录列表汇总卡新建编辑(CAS)详情页签成员加/移除导入预检交割预览
附带断言: 网络层零 PUT/DELETE零路径变量形态 /api/customer/<digits>/console/page error
"""
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 / "shots10"; 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-demo-浏览器-{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 ""))
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)
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 # L2 相似命中(同前缀历史数据)→ demo 三层查重确认流
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. 编辑(GET detail?id= → POST edit?id= 带 version/isBizNegotiated/isChild)
page.click(f"#listBody td.row:has-text('{NEW_NAME}')") # 从列表行打开详情
page.wait_for_selector("#headBox b", timeout=15000) # loadHead 完成
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 全字段客户:contacts/follow/opps/members/oplog 全点一遍)
page.reload(); page.wait_for_load_state("networkidle") # loadHead 会重渲染 headBox,reload 恢复 didInput
page.click("nav.tabs button[data-tab='detail']") # reload 后默认列表 tab,先切详情让 didInput 可见
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", "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("详情页签五页签无错误", 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?id= 数组表单 → member/remove?id=&memberUserId=)
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)
# 防御:上一轮残留的协同人先移除(member/remove 软删后可重复加入;重复添加会被两阶段校验整批拒绝)
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(f"#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)
after = page.locator("#subBox table tbody tr").count()
step("成员加/移除 member/add+remove", True, f"rows {before}->{page.locator('#subBox table tbody tr').count()} (加后={before+1})")
page.screenshot(path=str(SHOTS / "04-members.png"))
# 8. 导入(page 列表 + upload 预检,不 confirm)
page.click("nav.tabs button[data-tab='import']")
page.wait_for_timeout(800)
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 预检 + import/page", True, toast[:110])
page.screenshot(path=str(SHOTS / "05-import.png"))
# 9. 交割(page 列表 + preview)
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", tr_rows >= 0 and has_err == 0 and prev_fail == 0,
f"交接单行={tr_rows}, 预览行={prev_rows}, 预览失败toast={prev_fail}")
page.screenshot(path=str(SHOTS / "06-transfer.png"))
# 10. 网络层断言:新契约形态(零 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()
# ---- 正门清理:归档本次 + 上次孤儿(e2c-demo-浏览器* 前缀,admin 名下) ----
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())
tok = json.loads(urllib.request.urlopen(f"http://localhost:8080/api/auth/debug/token?userId={ADMIN}", timeout=10).read().decode())["data"]
found = post("http://localhost:8080/api/customer/workspace/page",
{"workspace": "mine", "keyword": "e2c-demo-浏览器", "archiveStatus": 1, "current": 1, "size": 50}, tok)
ids = [str(r["id"]) for r in (found.get("data") or {}).get("content", [])]
archived = []
for cid in ids:
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 archived:", archived)
# 汇总
summary = {"ts": TS, "new_customer": NEW_NAME, "new_id": new_id if 'new_id' in dir() else "",
"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-smoke10-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"])