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.
 
 
 
 
 

416 lines
19 KiB

# -*- coding: utf-8 -*-
r"""票 08:demo/index.html 浏览器级自测(Playwright + chromium headless)。
- 零容忍口径:console errors = 0、page errors = 0
- 每步截图 shots08/shot-NN-*.png;逐步 PASS/FAIL 记录 → demo-selftest-results.json
- 有状态动作选可逆/幂等路径(关注/取关、导入 INSERT 行必 FAILED=D-05 预期);归档/抛公海/交割分配不点(E2E 已覆盖 + D-07 超时 60s)
"""
import io, sys, os, json
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
from playwright.sync_api import sync_playwright
DEMO = 'file:///d:/code/crm-backend-matt/.scratch/customer-e2e/demo/index.html'
SHOTS = r'd:\code\crm-backend-matt\.scratch\customer-e2e\shots08'
XLSX = r'd:\code\crm-backend-matt\.scratch\customer-e2e\_import-heavy.xlsx'
ADMIN = '739564171091247104'
os.makedirs(SHOTS, exist_ok=True)
results, console_errors, page_errors = [], [], []
_shot = [0]
def shot(page, name):
_shot[0] += 1
p = os.path.join(SHOTS, f'shot-{_shot[0]:02d}-{name}.png')
page.screenshot(path=p, full_page=True)
print(f' [shot] {os.path.basename(p)}')
def step(name, fn):
print(f'== {name}')
try:
note = fn() or ''
results.append({'step': name, 'status': 'PASS', 'note': str(note)})
print(f' PASS {note}')
except Exception as e:
results.append({'step': name, 'status': 'FAIL', 'note': str(e)[:300]})
print(f' FAIL {e}')
def toasts(page):
page.wait_for_timeout(500)
return [t.inner_text() for t in page.locator('#toast .toast-item').all()]
def click_text(scope, text, timeout=6000):
scope.locator('button', has_text=text).first.click(timeout=timeout)
def row_count(page, body_id):
return page.locator(f'#{body_id} tr').count()
def sub(page, s):
page.locator(f'#tab-detail .subtabs button[data-s={s}]').click()
page.wait_for_timeout(700)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': 1500, 'height': 950})
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)))
page.on('dialog', lambda d: d.accept())
# ---------- 01 打开 + 登录 ----------
def s_open():
page.goto(DEMO)
page.wait_for_load_state('networkidle')
assert page.locator('nav.tabs button').count() == 5, '应有 5 个主 tab'
shot(page, 'init')
return 'tabs=5'
step('01 打开 file:// 页面', s_open)
def s_login():
page.fill('#uidInput', ADMIN)
page.evaluate('loginByUid()')
page.wait_for_timeout(1200)
who = page.locator('#whoBox').inner_text()
assert '未登录' not in who, f'登录失败 whoBox={who}'
n = row_count(page, 'listBody')
assert n > 0, '登录后列表应加载数据'
shot(page, 'login-mine-list')
return f'who={who[:20]} mine rows={n}'
step('02 debug token 登录 + 我的客户列表', s_login)
def s_overview_pool():
page.locator('#tab-list .viewbar [data-ws=overview]').click()
page.wait_for_timeout(900)
ov = row_count(page, 'listBody')
shot(page, 'overview')
page.locator('#tab-list .viewbar [data-ws=pool]').click()
page.wait_for_timeout(900)
pl = row_count(page, 'listBody')
info = page.locator('#pageInfo').inner_text()
shot(page, 'pool')
return f'overview rows={ov} pool rows={pl} info={info}'
step('03 三 workspace 切换', s_overview_pool)
def s_search():
page.locator('#tab-list .viewbar [data-ws=mine]').click()
page.wait_for_timeout(800)
page.fill('#fKeyword', '恒信达')
click_text(page.locator('#tab-list .viewbar'), '查询')
page.wait_for_timeout(900)
txt = page.locator('#listBody').inner_text()
assert '恒信达' in txt, '检索结果应含恒信达'
shot(page, 'search')
return 'keyword=恒信达 命中'
step('04 列表关键字检索', s_search)
def s_sumcards():
txt = page.locator('#summaryBox').inner_text()
assert txt.strip() and '随列表加载' not in txt, '汇总卡应有数据'
return txt.replace('\n', ' ')[:60]
step('05 汇总卡(board/summary)渲染', s_sumcards)
def s_batch_focus():
page.locator('#listBody tr input[type=checkbox]').first.check()
hint = page.locator('#pickHint').inner_text()
assert '已勾选 1' in hint, f'勾选提示异常: {hint}'
click_text(page.locator('#tab-list'), '批量关注')
ts = toasts(page)
shot(page, 'batch-focus')
assert any('成功' in t or '关注' in t for t in ts), f'批量关注 toast 异常: {ts}'
return f'{hint}; toast={ts[-1][:40]}'
step('06 勾选 + 批量关注', s_batch_focus)
# ---------- 详情 6 子页签 ----------
def s_open_detail():
page.locator('#listBody tr td.row').first.click()
page.wait_for_timeout(1200)
assert page.locator('#detailPanel').is_visible(), '详情面板应可见'
head = page.locator('#headBox').inner_text()
assert '负责人' in head, '公共头部应含负责人'
shot(page, 'detail-info')
return head.split('\n')[0][:50]
step('07 打开详情(detail-head 公共头部)', s_open_detail)
for tab, kw in [('info', '客户编号'), ('contacts', '新建联系人'), ('follow', '跟进'), ('opps', None), ('members', None), ('oplog', None)]:
def f(tab=tab, kw=kw):
sub(page, tab)
txt = page.locator('#subBox').inner_text()
assert txt.strip(), f'页签 {tab} 渲染为空'
assert '加载中' not in txt, f'页签 {tab} 停在加载中'
if kw:
assert kw in txt, f'页签 {tab} 应含「{kw}'
shot(page, f'detail-{tab}')
return f'{tab} 渲染 OK'
step(f'08 子页签 {tab}', f)
def s_follow_write():
sub(page, 'follow')
before = page.locator('#subBox .panel').count()
click_text(page.locator('#headBox'), '写跟进')
page.fill('#fContent', 'demo 自测跟进:浏览器级自测写入(票 08)')
click_text(page.locator('#dlgFoot'), '保存')
page.wait_for_timeout(1200)
ts = [t for t in toasts(page)]
after = page.locator('#subBox .panel').count()
shot(page, 'follow-written')
assert '跟进已保存' in ''.join(ts), f'写跟进 toast 异常: {ts}'
assert after >= before, '跟进条数应不减'
return f'panels {before}->{after}'
step('09 写跟进(follow_way_01 契约值)', s_follow_write)
C_NAME = 'demo-selftest-联系人甲'
def contact_rows(): # 数据行=含编辑按钮的行;空态行(无数据 td.empty)无按钮,排除
return [r for r in page.locator('#subBox tbody tr').all() if '编辑' in r.inner_text()]
def s_contact_create():
sub(page, 'contacts')
before = len(contact_rows())
click_text(page.locator('#headBox'), '+联系人')
page.fill('#ctName', C_NAME)
page.fill('#ctJob', '采购经理')
page.fill('#ctPhone', '13800001111')
page.select_option('#ctKey', '1')
click_text(page.locator('#dlgFoot'), '保存')
page.wait_for_timeout(1200)
ts = toasts(page)
after = len(contact_rows())
shot(page, 'contact-created')
assert '联系人已保存' in ''.join(ts), f'新建联系人 toast 异常: {ts}'
assert after == before + 1, f'新建后数据行应+1: {before}->{after}'
return f'rows {before}->{after}(含 name/jobTitleName/phone/isKeyContact 全契约字段)'
step('10 新建联系人(修复后字段)', s_contact_create)
def s_contact_edit():
row = page.locator('#subBox tbody tr', has_text=C_NAME).first
row.locator('button', has_text='编辑').click()
page.wait_for_timeout(400)
assert page.input_value('#ctName') == C_NAME, '编辑弹窗应回显姓名(c.name 契约)'
assert page.input_value('#ctJob') == '采购经理', '编辑弹窗应回显职务(jobTitleName)'
page.fill('#ctGift', 'demo 自测礼品备注')
click_text(page.locator('#dlgFoot'), '保存')
page.wait_for_timeout(1200)
ts = toasts(page)
shot(page, 'contact-edited')
assert '联系人已保存' in ''.join(ts), f'编辑联系人 toast 异常: {ts}'
return 'PUT /{id} 修复路径回显+保存 OK'
step('11 编辑联系人(PUT /{id} 修复验证)', s_contact_edit)
def s_contact_delete():
before = len(contact_rows())
row = page.locator('#subBox tbody tr', has_text=C_NAME).first
row.locator('button', has_text='删除').click()
after = before
for _ in range(10): # DELETE 成功后 switchSub 异步重载,轮询等待数据行下降
page.wait_for_timeout(400)
after = len(contact_rows())
if after < before:
break
ts = toasts(page)
shot(page, 'contact-deleted')
assert after == before - 1, f'删除后数据行应-1: {before}->{after}, toasts={ts}'
return f'rows {before}->{after}(DELETE /{id} 修复验证)'
step('12 删除联系人(DELETE /{id} 修复验证)', s_contact_delete)
# ---------- 新建客户三层查重两路 ----------
def s_need_confirm():
click_text(page.locator('header'), '新建客户')
page.fill('#cName', 'e2c-恒信达科技有限公司')
page.fill('#cType', 'customer_type_01')
page.fill('#cProv', '440000'); page.fill('#cCity', '440100')
page.fill('#cInd', 'gov')
click_text(page.locator('#dlgFoot'), '创建')
page.wait_for_timeout(1200)
out = page.locator('#cOut').inner_text()
assert '相似命中' in out, f'L2 相似 needConfirm 未触发: {out[:120]}'
shot(page, 'create-needconfirm')
page.evaluate('dlgClose()')
return out.split('\n')[0][:60]
step('13 新建客户 L2 相似 → needConfirm 弹出', s_need_confirm)
def s_credit_block():
click_text(page.locator('header'), '新建客户')
page.fill('#cName', 'demo-selftest-撞码客户')
page.fill('#cType', 'customer_type_01')
page.fill('#cProv', '440000'); page.fill('#cCity', '440100')
page.fill('#cInd', 'gov')
page.fill('#cCredit', '91440101E2CHXDA001') # seed simA 真实信用代码,才能触发 L3 67003
click_text(page.locator('#dlgFoot'), '创建')
page.wait_for_timeout(1200)
out = page.locator('#cOut').inner_text()
assert '67003' in out, f'L3 撞码 67003 未拦截(unifiedCreditCode 修复后应生效): {out[:120]}'
shot(page, 'create-credit-block')
page.evaluate('dlgClose()')
return '67003 硬拦展示 OK'
step('14 新建客户 L3 信用代码撞码 67003', s_credit_block)
def s_focus_star():
page.locator('#headBox button', has_text='关注').first.click()
page.wait_for_timeout(800)
t1 = ''.join(toasts(page))
page.locator('#headBox button', has_text='取关').first.click()
page.wait_for_timeout(800)
page.locator('#headBox button', has_text='重点客户').first.click()
page.wait_for_timeout(800)
page.locator('#headBox button', has_text='取消重点').first.click()
page.wait_for_timeout(800)
shot(page, 'focus-star')
assert '404' not in t1, f'关注请求 404(oneBy URL 形态修复后不应发生): {t1[:80]}'
return f'四连击完成(首条 toast: {t1[:40]}'
step('15 归属动作:关注/取关/重点/取消重点', s_focus_star)
# ---------- 交割 ----------
def s_transfer_list():
page.locator('nav.tabs button[data-tab=transfer]').click()
page.wait_for_timeout(1000)
txt = page.locator('#trBody').inner_text()
assert '暂无交接单' not in txt, f'交接单列表空态(tab 按钮 transferPage(1) 翻页缺陷修复后应显示第 1 页): {txt[:40]}'
n = row_count(page, 'trBody')
shot(page, 'transfer-list')
return f'trBody rows={n}'
step('16 交割列表 + 预览', s_transfer_list)
def s_transfer_preview():
click_text(page.locator('#tab-transfer'), '预览名下待交接客户')
page.wait_for_timeout(1000)
txt = page.locator('#tab-transfer').inner_text()
shot(page, 'transfer-preview')
assert '待交接' in txt or '客户' in txt, '预览应输出名下客户'
page.evaluate('dlgClose()') # 预览是 dlgOpen 弹窗,不关会拦截后续所有点击(第一轮级联失败根因)
return 'preview OK'
step('17 交割发起前预览', s_transfer_preview)
def s_transfer_detail():
row = page.locator('#trBody tr').first
row.locator('button', has_text='详情').click()
page.wait_for_timeout(1200)
assert page.locator('#trDetailPanel').is_visible(), '交接单详情面板应可见'
txt = page.locator('#trDetailPanel').inner_text()
shot(page, 'transfer-detail')
return txt.split('\n')[0][:50]
step('18 交接单详情(GET /transfer/{id}', s_transfer_detail)
# ---------- 导入全链(D-05/D-04 展示) ----------
def s_import_list():
page.locator('nav.tabs button[data-tab=import]').click()
page.wait_for_timeout(1000)
n = row_count(page, 'impBody')
assert n > 0, '导入任务列表应渲染'
shot(page, 'import-list')
return f'impBody rows={n}'
step('19 导入任务列表渲染', s_import_list)
def s_import_upload():
page.set_input_files('#impFile', XLSX)
page.select_option('#impMode', 'APPEND_ONLY')
click_text(page.locator('#tab-import'), '上传预检')
page.wait_for_timeout(2500)
ts = ''.join(toasts(page))
first = page.locator('#impBody tr').first.inner_text()
shot(page, 'import-upload')
assert ('预检' in ts or '任务' in ts or 'DRAFT' in ts or '待确认' in first), f'上传预检反馈异常: {ts[:80]} | {first[:60]}'
return f'toast={ts[:50]}'
step('20 导入上传预检(DRAFT 两段式)', s_import_upload)
def s_import_confirm():
first = page.locator('#impBody tr').first
if first.locator('button', has_text='确认执行').count() == 0:
return 'SKIP:首行非待确认态'
first.locator('button', has_text='确认执行').click()
page.wait_for_timeout(3500) # confirm + 1.5s 自动刷新 + 异步执行
first = page.locator('#impBody tr').first
txt = first.inner_text()
shot(page, 'import-result')
assert ('DONE' in txt) or ('FAILED' in txt) or ('RUNNING' in txt), f'任务终态未出现(IMP_ST 英文文案): {txt[:80]}'
return '首行状态: ' + ' '.join(txt.split())[:50]
step('21 确认执行导入(D-05:INSERT 行必 FAILED)', s_import_confirm)
def s_import_failures():
first = page.locator('#impBody tr').first
btn = first.locator('button', has_text='失败明细')
if btn.count() == 0:
first.locator('button', has_text='结果').click()
page.wait_for_timeout(800)
page.evaluate('dlgClose()')
return 'SKIP:首行无失败明细入口'
btn.click()
page.wait_for_timeout(1000)
txt = page.locator('#dlgBody').inner_text()
shot(page, 'import-failures')
page.evaluate('dlgClose()')
return ('明细弹窗: ' + ' '.join(txt.split())[:70]) if txt.strip() else ''
step('22 失败明细弹窗(D-04:0 行提示)', s_import_failures)
# ---------- 设置:提醒规则 + 查重试玩 ----------
def s_reminder_rw():
page.locator('nav.tabs button[data-tab=settings]').click()
page.wait_for_timeout(1000)
orig_days = page.input_value('#rFirstDays')
page.fill('#rFirstDays', '31')
click_text(page.locator('#tab-settings'), '保存')
page.wait_for_timeout(1200)
ts1 = ''.join(toasts(page))
click_text(page.locator('#tab-settings'), '刷新')
page.wait_for_timeout(900)
now_days = page.input_value('#rFirstDays')
shot(page, 'reminder-saved')
assert now_days == '31', f'保存 31 后回读应 31: {now_days}'
return f'orig={orig_days} -> 31 保存回读 OK({ts1[:30]}'
step('23 提醒规则读改写(PUT JSON 全量覆盖)', s_reminder_rw)
def s_reminder_64023():
page.fill('#rFirstDays', '0')
click_text(page.locator('#tab-settings'), '保存')
page.wait_for_timeout(1200)
ts = ''.join(toasts(page))
shot(page, 'reminder-64023')
assert '64023' in ts, f'64023 错误提示未出现: {ts[:80]}'
click_text(page.locator('#tab-settings'), '刷新')
page.wait_for_timeout(800)
return '64023 提示 OK'
step('24 提醒规则 64023 负路径(天数非正)', s_reminder_64023)
def s_reminder_restore():
page.fill('#rFirstDays', '30')
click_text(page.locator('#tab-settings'), '保存')
page.wait_for_timeout(1200)
click_text(page.locator('#tab-settings'), '刷新')
page.wait_for_timeout(800)
v = page.input_value('#rFirstDays')
return f'还原 firstTriggerDays=30(回读 {v}'
step('25 提醒规则还原基线', s_reminder_restore)
def s_checks():
page.fill('#ckName', '恒信达')
page.locator('#tab-settings button', has_text='check-name').click()
page.wait_for_timeout(900)
o1 = page.locator('#ckOut').inner_text()
page.fill('#ckCredit', '91440101MA9ABC1234')
page.locator('#tab-settings button', has_text='check-credit-code').click()
page.wait_for_timeout(900)
o2 = page.locator('#ckOut').inner_text()
page.fill('#ckPhone', '13812340001')
page.locator('#tab-settings button', has_text='check-phone').click()
page.wait_for_timeout(900)
o3 = page.locator('#ckOut').inner_text()
shot(page, 'checks')
assert '命中' in o1 or 'hit' in o1.lower() or '[' in o1, f'check-name 输出异常: {o1[:60]}'
return 'check-name / check-credit-code / check-phone 三连 OK'
step('26 查重试玩三端点', s_checks)
browser.close()
# ---------- 汇总 ----------
passed = [r for r in results if r['status'] == 'PASS']
failed = [r for r in results if r['status'] == 'FAIL']
print('\n================ 自测汇总 ================')
for r in results:
print(f" {r['status']:4} {r['step']} {r['note'][:80]}")
print(f'\nsteps: {len(passed)} PASS / {len(failed)} FAIL / 截图 {_shot[0]}')
print(f'console errors: {len(console_errors)}')
for e in console_errors:
print(' CE:', e[:160])
print(f'page errors: {len(page_errors)}')
for e in page_errors:
print(' PE:', e[:160])
json.dump({'results': results, 'console_errors': console_errors, 'page_errors': page_errors,
'shots': _shot[0]},
open(os.path.join(os.path.dirname(SHOTS), 'demo-selftest-results.json'), 'w', encoding='utf-8'),
ensure_ascii=False, indent=2)
print('results json written.')