# -*- coding: utf-8 -*- """票 10 demo 契约同步 · 浏览器级主流程点通(Playwright, chromium headless) 被测: .scratch/customer-e2e/demo/index.html?api=http://localhost:8080 流程: 登录→列表→汇总卡→新建→编辑(CAS)→详情页签→成员加/移除→导入预检→交割预览 附带断言: 网络层零 PUT/DELETE、零路径变量形态 /api/customer//、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// 路径变量) 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"])