# -*- coding: utf-8 -*- """票 02 主 demo 单入口收编 · 浏览器级冒烟(Playwright, chromium headless, route 桩) 被测: .scratch/customer-e2e/demo/index.html?api=http://mock-api(内存后端桩,修订⑧契约) 覆盖: 列表关注星标列(toggleFocus 双向) / contactsShell 常驻面板(旧端点列表+图谱双加载) / 图谱视图(修订⑧派生公司边) / reveal 明文双写 / quickAdd 快速添加双刷 / batchEdit 页内编辑 / graph/save 版本 CAS / 导入三段式(cimp) / export / delContact gConfirm 双刷 / 页签显隐互斥 / 网络层零 PUT/DELETE 视口 1720×1080(票 02 规格);全量交互回归归票 04。 """ import io, os, sys, json, re from urllib.parse import parse_qs sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') from playwright.sync_api import sync_playwright ROOT = r"e:\code\crm-backend-matt\.scratch\customer-e2e" SHOTS = os.path.join(ROOT, "shots-t02"); os.makedirs(SHOTS, exist_ok=True) URL = 'file:///' + os.path.join(ROOT, 'demo', 'index.html').replace('\\', '/') + '?api=http://mock-api' CUST_A = '900000000000000001' CUST_B = '900000000000000002' def mask(p): return p[:3] + '****' + p[-4:] if p else '--' JOB_TREE = [ {'name': '高层管理', 'code': 'executive', 'children': [ {'name': '董事长', 'code': 'chairman', 'level': 10}, {'name': '股东', 'code': 'shareholder', 'level': 10}]}, {'name': '采购', 'code': 'purchase', 'children': [ {'name': '采购经理', 'code': 'purchase_manager', 'level': 6}, {'name': '采购专员', 'code': 'purchase_specialist', 'level': 3}]}, {'name': '市场', 'code': 'marketing', 'children': [ {'name': '市场经理', 'code': 'marketing_manager', 'level': 6}, {'name': '市场专员', 'code': 'marketing_specialist', 'level': 3}]}, ] SOURCE_ITEMS = [{'name': '组会', 'code': 'group_meeting'}, {'name': '组局', 'code': 'group_activity'}, {'name': '转介绍', 'code': 'referral'}, {'name': '其他', 'code': 'other'}] PHONE = {'c1': '13123481545', 'c2': '13897283272', 'c3': '13897281545', 'c4': '13897283272', 'c5': '13812383272', 'c6': '13897283272', 'c7': '13127283272', 'c8': '13897283272', 'c9': '13897283272'} def find_job(code): for g in JOB_TREE: for j in g['children']: if j['code'] == code: return g, j return None, None NODES = [ {'contactId': 'c1', 'name': '王强', 'jobTitleCategory': '高层管理', 'jobTitleName': '董事长', 'level': 10, 'levelSource': 1, 'phoneMasked': mask('13123481545'), 'isKeyContact': 0}, {'contactId': 'c2', 'name': '陈大壮', 'jobTitleCategory': '高层管理', 'jobTitleName': '股东', 'level': 10, 'levelSource': 2, 'phoneMasked': mask('13897283272'), 'isKeyContact': 1}, {'contactId': 'c3', 'name': '张明', 'jobTitleCategory': '高层管理', 'jobTitleName': '股东', 'level': 10, 'levelSource': 1, 'phoneMasked': mask('13897281545'), 'isKeyContact': 0}, {'contactId': 'c4', 'name': '胡子', 'jobTitleCategory': '采购', 'jobTitleName': '采购经理', 'level': 6, 'levelSource': 1, 'phoneMasked': mask('13897283272'), 'isKeyContact': 0}, {'contactId': 'c5', 'name': '张华', 'jobTitleCategory': '市场', 'jobTitleName': '市场经理', 'level': 6, 'levelSource': 1, 'phoneMasked': mask('13812383272'), 'isKeyContact': 0}, {'contactId': 'c6', 'name': '卢伟', 'jobTitleCategory': '市场', 'jobTitleName': '市场经理', 'level': 6, 'levelSource': 1, 'phoneMasked': mask('13897283272'), 'isKeyContact': 0}, {'contactId': 'c7', 'name': '邓伦', 'jobTitleCategory': '采购', 'jobTitleName': '采购专员', 'level': 3, 'levelSource': 1, 'phoneMasked': mask('13127283272'), 'isKeyContact': 0}, {'contactId': 'c8', 'name': '王大牛', 'jobTitleCategory': '市场', 'jobTitleName': '市场专员', 'level': 3, 'levelSource': 1, 'phoneMasked': mask('13897283272'), 'isKeyContact': 0}, {'contactId': 'c9', 'name': '江小白', 'jobTitleCategory': '投标商务', 'jobTitleName': '其他职务', 'level': None, 'levelSource': None, 'phoneMasked': mask('13897283272'), 'isKeyContact': 0}, ] EDGES = [{'parentId': 'c4', 'childId': 'c7'}] # 修订⑧:唯一手动边;公司边派生不入快照 STATE = {'version': 1, 'taskSeq': 501} # 旧端点全字段列表(票02 数据分工:列表=/contacts,图谱=graph/detail;reveal 双写两处) def mk_contact(cid, name, job, internal, src, gift): return {'id': cid, 'name': name, 'jobTitleName': job, 'phone': mask(PHONE[cid]), 'isKeyContact': 1 if cid == 'c2' else 0, 'isInternal': internal, 'source': src, 'giftRemark': gift} CONTACTS = [ mk_contact('c1', '王强', '董事长', 0, 'group_meeting', '茶叶'), mk_contact('c2', '陈大壮', '股东', 0, 'referral', ''), mk_contact('c3', '张明', '股东', 1, '', ''), mk_contact('c4', '胡子', '采购经理', 0, 'group_activity', '酒'), mk_contact('c5', '张华', '市场经理', 0, '', ''), mk_contact('c6', '卢伟', '市场经理', 0, '', ''), mk_contact('c7', '邓伦', '采购专员', 0, '', ''), mk_contact('c8', '王大牛', '市场专员', 0, '', ''), mk_contact('c9', '江小白', '其他职务', 0, '', ''), ] CUSTS = { CUST_A: {'id': CUST_A, 'customerName': 'e2c-星标-甲', 'customerNo': 'C-9001', 'customerStage': 1, 'customerStarLevel': 3, 'ownerNameSnapshot': '演示管理员', 'ownerDeptNameSnapshot': '销售一部', 'opportunityCount': 2, 'lastValidFollowTime': '2026-09-01 10:00:00', 'focusFlag': 0}, CUST_B: {'id': CUST_B, 'customerName': 'e2c-星标-乙', 'customerNo': 'C-9002', 'customerStage': 2, 'customerStarLevel': 2, 'ownerNameSnapshot': '演示管理员', 'ownerDeptNameSnapshot': '销售一部', 'opportunityCount': 0, 'lastValidFollowTime': None, 'focusFlag': 1}, } def env(data): return json.dumps({'code': 0, 'success': True, 'message': 'success', 'data': data}, ensure_ascii=False) def env_err(code, msg): return json.dumps({'code': code, 'success': False, 'message': msg, 'data': None}, ensure_ascii=False) def xlsx(route): route.fulfill(status=200, content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', body=b'PK\x03\x04t02') def handle_api(route, request): method = request.method path = re.sub(r'^https?://mock-api', '', request.url).split('?')[0] qs = parse_qs(request.url.split('?')[1]) if '?' in request.url else {} body = request.post_data or '' form = parse_qs(body) if method == 'POST' else {} if path == '/api/dict/item/tree' and method == 'GET': route.fulfill(status=200, content_type='application/json', body=env(JOB_TREE)); return if path == '/api/dict/item/enabled-list' and method == 'GET': route.fulfill(status=200, content_type='application/json', body=env(SOURCE_ITEMS)); return if path == '/api/customer/workspace/page': content = list(CUSTS.values()) route.fulfill(status=200, content_type='application/json', body=env({'content': content, 'total': len(content)})); return if path == '/api/customer/workspace/board/summary': route.fulfill(status=200, content_type='application/json', body=env([{'key': 'mine', 'count': len(CUSTS)}])); return if path in ('/api/customer/focus', '/api/customer/unfocus'): cid = qs.get('id', [''])[0] if cid in CUSTS: CUSTS[cid]['focusFlag'] = 1 if path == '/api/customer/focus' else 0 route.fulfill(status=200, content_type='application/json', body=env(None)); return if path == '/api/customer/detail-head': c = CUSTS.get(qs.get('id', [''])[0], {}) route.fulfill(status=200, content_type='application/json', body=env({ 'customerName': c.get('customerName'), 'customerNo': c.get('customerNo'), 'customerStage': c.get('customerStage'), 'customerStarLevel': c.get('customerStarLevel'), 'archiveStatus': 1, 'ownerUserNameSnapshot': c.get('ownerNameSnapshot'), 'lastFollowSummary': '月度回访', 'lastFollowTime': '2026-09-01 10:00:00', 'nextFollowTime': None, 'opportunityCount': c.get('opportunityCount'), 'projectCount': None})); return if path == '/api/customer/contacts' and method == 'GET': route.fulfill(status=200, content_type='application/json', body=env(CONTACTS)); return if path == '/api/customer/contact/graph/detail': route.fulfill(status=200, content_type='application/json', body=env({'version': STATE['version'], 'nodes': NODES, 'edges': EDGES})); return if path == '/api/customer/contact/graph/save': v = int(form.get('version', ['0'])[0]) if v != STATE['version']: route.fulfill(status=200, content_type='application/json', body=env_err(67017, '图谱版本已变化,请刷新后重试')); return new_edges = [] i = 0 while 'edges[%d].parentId' % i in form: new_edges.append({'parentId': form['edges[%d].parentId' % i][0], 'childId': form['edges[%d].childId' % i][0]}); i += 1 n = len([k for k in form if k.endswith('.contactId')]) for j in range(n): cid = form['nodes[%d].contactId' % j][0] lvl = form.get('nodes[%d].level', [''])[0] for node in NODES: if node['contactId'] == cid: node['level'] = int(lvl) if lvl != '' else None EDGES[:] = new_edges STATE['version'] += 1 route.fulfill(status=200, content_type='application/json', body=env(STATE['version'])); return if path == '/api/customer/contact/reveal': route.fulfill(status=200, content_type='application/json', body=env(PHONE.get(qs.get('id', [''])[0], ''))); return if path == '/api/customer/contact/quickAdd': nrows = len([k for k in form if k.endswith('.name')]) for j in range(nrows): code = form['rows[%d].jobTitleCode' % j][0] g, job = find_job(code) cid = 'c%d' % (len(NODES) + 1) phone = form.get('rows[%d].phone' % j, [''])[0] PHONE[cid] = phone name = form['rows[%d].name' % j][0] NODES.append({'contactId': cid, 'name': name, 'jobTitleCategory': g['name'] if g else '', 'jobTitleName': job['name'] if job else '', 'level': job['level'] if job else None, 'levelSource': 1, 'phoneMasked': mask(phone), 'isKeyContact': int(form.get('rows[%d].isKeyContact' % j, ['0'])[0] or 0)}) CONTACTS.append({'id': cid, 'name': name, 'jobTitleName': job['name'] if job else '', 'phone': mask(phone), 'isKeyContact': int(form.get('rows[%d].isKeyContact' % j, ['0'])[0] or 0), 'isInternal': 0, 'source': form.get('rows[%d].source' % j, [''])[0], 'giftRemark': ''}) STATE['version'] += 1 route.fulfill(status=200, content_type='application/json', body=env({'successCount': nrows, 'failedRows': []})); return if path == '/api/customer/contact/batchEdit': nrows = len([k for k in form if k.endswith('.id')]) job_changed = False for j in range(nrows): cid = form['rows[%d].id' % j][0] code = form.get('rows[%d].jobTitleCode' % j, [''])[0] g, job = find_job(code) for node in NODES: if node['contactId'] != cid: continue if 'rows[%d].name' % j in form: node['name'] = form['rows[%d].name' % j][0] if code: node['jobTitleName'] = job['name'] if job else node['jobTitleName'] node['jobTitleCategory'] = g['name'] if g else node['jobTitleCategory'] node['level'] = job['level'] if job else None node['levelSource'] = 1 job_changed = True ph = form.get('rows[%d].phone' % j, [''])[0] if ph and '*' not in ph: PHONE[cid] = ph; node['phoneMasked'] = mask(ph) node['isKeyContact'] = int(form.get('rows[%d].isKeyContact' % j, [str(node['isKeyContact'])])[0] or 0) for c in CONTACTS: if str(c['id']) == cid: if 'rows[%d].name' % j in form: c['name'] = form['rows[%d].name' % j][0] if code: c['jobTitleName'] = job['name'] if job else c['jobTitleName'] ph = form.get('rows[%d].phone' % j, [''])[0] if ph and '*' not in ph: c['phone'] = mask(ph) if 'rows[%d].source' % j in form: c['source'] = form['rows[%d].source' % j][0] if 'rows[%d].isKeyContact' % j in form: c['isKeyContact'] = int(form['rows[%d].isKeyContact' % j][0] or 0) if job_changed: STATE['version'] += 1 route.fulfill(status=200, content_type='application/json', body=env({'successCount': nrows, 'failedRows': []})); return if path == '/api/customer/contact/delete': cid = qs.get('id', [''])[0] NODES[:] = [x for x in NODES if x['contactId'] != cid] EDGES[:] = [e for e in EDGES if cid not in (e['parentId'], e['childId'])] CONTACTS[:] = [c for c in CONTACTS if str(c['id']) != cid] STATE['version'] += 1 route.fulfill(status=200, content_type='application/json', body=env(None)); return if path == '/api/customer/contact/export': xlsx(route); return if path == '/api/customer/contact/import/template': xlsx(route); return if path == '/api/customer/contact/import/upload': route.fulfill(status=200, content_type='application/json', body=env({ 'taskId': STATE['taskSeq'], 'customerId': '1', 'importMode': form.get('importMode', [''])[0], 'duplicateStrategy': form.get('duplicateStrategy', [''])[0], 'totalCount': 5, 'insertCount': 3, 'updateCount': 1, 'failCount': 1, 'suspectCount': 0, 'rows': [{'rowNum': 1, 'identity': '赵新', 'verdict': 'INSERT', 'reason': ''}, {'rowNum': 2, 'identity': '钱新', 'verdict': 'INSERT', 'reason': ''}, {'rowNum': 3, 'identity': '孙新', 'verdict': 'INSERT', 'reason': ''}, {'rowNum': 4, 'identity': '胡子', 'verdict': 'UPDATE', 'reason': ''}, {'rowNum': 5, 'identity': '', 'verdict': 'FAIL', 'reason': '姓名必填'}]})); return if path == '/api/customer/contact/import/confirm': route.fulfill(status=200, content_type='application/json', body=env(int(qs.get('taskId', ['0'])[0]))); return if path == '/api/customer/contact/import/result': route.fulfill(status=200, content_type='application/json', body=env({ 'taskId': int(qs.get('taskId', ['0'])[0]), 'status': 2, 'totalCount': 5, 'insertCount': 3, 'updateCount': 1, 'unchangedCount': 0, 'failCount': 1, 'suspectCount': 0, 'failReason': None})); return if path == '/api/customer/contact/import/failures': route.fulfill(status=200, content_type='application/json', body=env([ {'rowNum': 5, 'name': '', 'phone': '--', 'failReason': '姓名必填'}])); return route.fulfill(status=200, content_type='application/json', body=env(None)) errors, steps, api_urls = [], [], [] def step(name, ok, extra=''): steps.append({'step': name, 'ok': bool(ok), 'extra': str(extra)}) print(('PASS' if ok else 'FAIL'), '|', name, '|', extra) with sync_playwright() as p: b = p.chromium.launch() pg = b.new_page(viewport={'width': 1720, 'height': 1080}, accept_downloads=True) pg.on('console', lambda m: errors.append('console.error: ' + m.text) if m.type == 'error' else None) pg.on('pageerror', lambda e: errors.append('pageerror: ' + str(e))) pg.on('request', lambda r: api_urls.append(r.method + ' ' + r.url) if 'mock-api' in r.url and '/api/' in r.url else None) pg.route('http://mock-api/**', handle_api) pg.add_init_script("localStorage.setItem('e2c_token','t02-smoke-token');" "localStorage.setItem('e2c_me', JSON.stringify({userId:'739564171091247104', userName:'演示管理员'}));") pg.goto(URL) pg.wait_for_timeout(1000) # 1 列表 + 关注星标列 pg.wait_for_selector('#listBody tr td:not(.empty)', timeout=15000) ths = [t.strip() for t in pg.locator('#tab-list thead th').all_text_contents()] step('列表首列=关注', bool(ths) and ths[0] == '关注', '|'.join(ths[:3])) stars = pg.locator('#listBody .star-link').count() step('星标列渲染 == 2 行', stars == 2, stars) off1 = pg.locator('#listBody tr').first.locator('.star-link.off').count() off2 = pg.locator('#listBody tr').nth(1).locator('.star-link.off').count() step('focusFlag 0=☆ / 1=★', off1 == 1 and off2 == 0, 'row1_off=%d row2_off=%d' % (off1, off2)) pg.screenshot(path=os.path.join(SHOTS, '01-list-focus.png'), full_page=True) # 2 toggleFocus 双向(桩记账 → 重拉后星标翻转) pg.locator('#listBody tr').first.locator('.star-link').click() pg.wait_for_selector(".toast-item:has-text('已关注')", timeout=8000) pg.wait_for_timeout(800) off1b = pg.locator('#listBody tr').first.locator('.star-link.off').count() step('关注 → 重拉后星标转 ★', off1b == 0, 'row1_off=%d' % off1b) pg.locator('#listBody tr').first.locator('.star-link').click() pg.wait_for_selector(".toast-item:has-text('已取关')", timeout=8000) pg.wait_for_timeout(800) off1c = pg.locator('#listBody tr').first.locator('.star-link.off').count() step('取关 → 星标回 ☆', off1c == 1, 'row1_off=%d' % off1c) # 3 进详情(D.cname 记录 + +快速添加按钮) pg.locator('#listBody td.row').first.click() pg.wait_for_selector('#headBox b', timeout=15000) qabtn = pg.locator('#headBox button:has-text("快速添加")').count() oldbtn = pg.locator('#headBox button:has-text("+联系人")').count() step('loadHead + 快速添加按钮替换旧+联系人', qabtn == 1 and oldbtn == 0, 'qa=%d old=%d' % (qabtn, oldbtn)) # 4 contacts 页签 → contactsShell 常驻面板(双加载) pg.click("#detailPanel .subtabs button[data-s='contacts']") pg.wait_for_selector('#contactTbody tr', timeout=15000) shell_visible = pg.evaluate("document.getElementById('contactsShell').style.display !== 'none'") sub_hidden = pg.evaluate("document.getElementById('subBox').style.display === 'none'") step('contactsShell 显示 + subBox 隐藏', shell_visible and sub_hidden, 'shell=%s sub=%s' % (shell_visible, sub_hidden)) crows = pg.locator('#contactTbody tr').count() step('联系人列表 9 行(旧端点全字段)', crows == 9, crows) masked9 = pg.locator('#contactTbody .phone-masked').count() step('列表电话脱敏 == 9', masked9 == 9, masked9) pg.screenshot(path=os.path.join(SHOTS, '02-contacts-list.png'), full_page=True) # 5 图谱视图(修订⑧:1 手动边 + 3 公司派生边) pg.click('#pillGraph') pg.wait_for_timeout(700) cards = pg.locator('#canvasInner .gcard:not(.company)').count() edges = pg.locator('#edgeSvg g.edge-g').count() comp = pg.locator('#edgeSvg .edge-company').count() compdel = pg.locator('#edgeSvg .edge-company .edge-del').count() badges = pg.locator('.top-badge').count() step('图谱卡片 9', cards == 9, cards) step('边 4 = 1 手动 + 3 公司派生', edges == 4 and comp == 3, 'edges=%d comp=%d' % (edges, comp)) step('公司派生边无 ×(修订⑧ D8)', compdel == 0, compdel) step('顶层徽标 3(等级10)', badges == 3, badges) pg.screenshot(path=os.path.join(SHOTS, '03-graph.png'), full_page=True) # 6 reveal 明文(列表/图谱双写) pg.click('#pillList') pg.wait_for_timeout(300) pg.locator('#contactTbody .phone-masked').first.click() pg.wait_for_timeout(500) plain = pg.locator('#contactTbody .phone-plain').count() step('reveal 明文(列表)', plain >= 1, plain) pg.click('#pillGraph') pg.wait_for_timeout(500) gplain = pg.locator('.gcard .gphone.revealed').count() step('reveal 双写图谱节点', gplain >= 1, gplain) pg.click('#pillList') pg.wait_for_timeout(300) # 7 快速添加(quickAdd → 双刷) pg.click('#btnAddContact') pg.wait_for_selector('#qaOverlay.show', timeout=8000) nrows = pg.locator('#qaTbody tr').count() step('qa 弹窗默认 2 行', nrows == 2, nrows) pg.fill('#qaTbody tr[data-qa="0"] input[data-qf="name"]', '测试员甲') pg.select_option('#qaTbody tr[data-qa="0"] select[data-qf="cat"]', 'marketing') pg.wait_for_timeout(200) pg.select_option('#qaTbody tr[data-qa="0"] select[data-qf="job"]', 'marketing_specialist') pg.fill('#qaTbody tr[data-qa="0"] input[data-qf="phone"]', '13900001111') pg.click('#qaOverlay .modal-foot .btn-primary') pg.wait_for_selector(".toast-item:has-text('成功添加')", timeout=10000) pg.wait_for_timeout(800) crows2 = pg.locator('#contactTbody tr').count() step('quickAdd → 双刷列表 10 行', crows2 == 10, crows2) pg.screenshot(path=os.path.join(SHOTS, '04-qa-added.png'), full_page=True) # 8 页内批量编辑(batchEdit 原子批 → 双刷) pg.click('#btnEditList') pg.wait_for_timeout(300) pg.fill('#contactTbody tr[data-id="c10"] input[data-f="name"]', '测试员乙') pg.wait_for_timeout(200) step('dirty 行高亮', pg.locator('.dirty-row').count() == 1, pg.locator('.dirty-row').count()) pg.click('#btnSaveList') pg.wait_for_selector(".toast-item:has-text('保存全部成功')", timeout=10000) pg.wait_for_timeout(800) nm = pg.locator('#contactTbody tr[data-id="c10"] input[data-f="name"]').input_value() step('batchEdit 保存后双刷回显新名', nm == '测试员乙', nm) pg.click('#btnEditList') pg.wait_for_timeout(300) # 9 图谱编辑 + save(版本 CAS) pg.click('#pillGraph') pg.wait_for_timeout(400) pg.click('#btnGraphEdit') pg.wait_for_timeout(300) dots = pg.locator('.gcard .dot').count() step('编辑态圆点出现', dots > 0, dots) pg.evaluate("tryConnect('c5','c8')") pg.wait_for_timeout(300) pend = pg.locator('#edgeSvg path.edge-pending').count() step('tryConnect → pending 边', pend >= 1, pend) pg.locator('#edgeSvg g.edge-g:not(.edge-company)').last.locator('.edge-del').click() pg.wait_for_timeout(300) pg.click('#btnGraphSave') pg.wait_for_selector(".toast-item:has-text('保存成功')", timeout=10000) ver = pg.evaluate("G.version") # 版本链路: 初始 1 → quickAdd 成功 bump(1→2, qaHint 契约) → batchEdit 职务变更联动 bump(2→3, D1) # → graph/save CAS(提交 version=3 与服务端一致)成功后返回 4。精确 == 4 证明全链路 CAS 无漂移。 step('graph/save 版本链路 1→2→3→4(CAS 全链路)', ver == 4, ver) pg.click('#btnGraphEdit') pg.wait_for_timeout(300) pg.screenshot(path=os.path.join(SHOTS, '05-graph-saved.png'), full_page=True) # 10 导入三段式(cimp 六端点) pg.click('#pillList') pg.wait_for_timeout(300) pg.click('#contactsShell button[onclick="openCimp()"]') pg.wait_for_selector('#cimpOverlay.show', timeout=8000) pg.set_input_files('#cimpFile', {'name': 'contacts.xlsx', 'mimeType': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'buffer': b'PK\x03\x04t02fake'}) pg.click('#cimpUploadBtn') pg.wait_for_timeout(600) head2 = pg.text_content('#cimpPreviewHead') or '' step('cimp 上传预检(任务 501, 共 5 行)', '任务 501' in head2 and '共 5 行' in head2, head2[:60]) pg.click('#cimpConfirmBtn') pg.wait_for_timeout(1600) head3 = pg.text_content('#cimpResultHead') or '' step('cimp 轮询终态 已完成(失败 1)', '已完成' in head3 and '失败 1' in head3, head3[:60]) fails = pg.locator('#cimpFailTbody tr').count() step('失败明细 1 行', fails == 1, fails) pg.click('#cimpStep3 button.btn-primary') pg.wait_for_timeout(400) closed = pg.evaluate("!document.getElementById('cimpOverlay').classList.contains('show')") step('导入弹窗关闭', closed, '') pg.screenshot(path=os.path.join(SHOTS, '06-cimp.png'), full_page=True) # 11 导出(POST /export → blob 下载) pg.click('#contactsShell button[onclick="exportContacts()"]') pg.wait_for_selector(".toast-item:has-text('导出已提交')", timeout=8000) step('export POST 已发出', any('POST' in u and '/api/customer/contact/export' in u for u in api_urls), '') # 12 delContact(gConfirm → 双刷) before = pg.locator('#contactTbody tr').count() pg.click('#contactTbody tr[data-id="c10"] [data-op="del"]') pg.wait_for_selector('dialog#dlg[open]', timeout=8000) dlg_title = (pg.text_content('#dlgTitle') or '').strip() pg.click('.dfoot button.ok') pg.wait_for_selector(".toast-item:has-text('已删除')", timeout=10000) pg.wait_for_timeout(800) after = pg.locator('#contactTbody tr').count() step('delContact gConfirm 弹窗', dlg_title == '删除联系人', dlg_title) step('删除后双刷 10→9', before == 10 and after == 9, '%d→%d' % (before, after)) pg.screenshot(path=os.path.join(SHOTS, '07-deleted.png'), full_page=True) # 13 页签显隐互斥(切走 follow) pg.click("#detailPanel .subtabs button[data-s='follow']") pg.wait_for_timeout(600) shell_hidden = pg.evaluate("document.getElementById('contactsShell').style.display === 'none'") sub_visible = pg.evaluate("document.getElementById('subBox').style.display !== 'none'") step('切走 follow: shell 隐藏 subBox 显示', shell_hidden and sub_visible, 'shell=%s sub=%s' % (shell_hidden, sub_visible)) b.close() # ---- 网络层断言 ---- step('console/pageerror 零', not errors, '; '.join(errors[:3])) bad_method = [u for u in api_urls if u.startswith(('PUT ', 'DELETE '))] step('零 PUT/DELETE', not bad_method, str(bad_method[:3])) focus_cnt = sum(1 for u in api_urls if '/api/customer/focus?' in u) unfocus_cnt = sum(1 for u in api_urls if '/api/customer/unfocus?' in u) step('focus/unfocus 各一次', focus_cnt == 1 and unfocus_cnt == 1, 'focus=%d unfocus=%d' % (focus_cnt, unfocus_cnt)) save_cnt = sum(1 for u in api_urls if '/contact/graph/save' in u) step('graph/save POST 一次', save_cnt == 1, save_cnt) twelve = ['contact/graph/detail', 'contact/graph/save', 'contact/reveal', 'contact/quickAdd', 'contact/batchEdit', 'contact/export', 'contact/import/template', 'contact/import/upload', 'contact/import/confirm', 'contact/import/result', 'contact/import/failures', '/contacts?'] hit = sum(1 for ep in twelve if any(ep in u for u in api_urls)) step('12 端点族全部被触发(本冒烟可及 11 + contacts)', hit >= 11, '%d/%d' % (hit, len(twelve))) print('\n==== summary ====%d steps, %d failed' % (len(steps), sum(1 for s in steps if not s['ok']))) with open(os.path.join(ROOT, 't02-smoke-result.json'), 'w', encoding='utf-8') as f: json.dump({'steps': steps, 'errors': errors, 'api_urls': api_urls}, f, ensure_ascii=False, indent=1) print('ALL_PASS:', all(s['ok'] for s in steps))