# -*- 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.')