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.
430 lines
27 KiB
430 lines
27 KiB
# -*- 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))
|
|
|