# -*- coding: utf-8 -*- """opportunity-acceptance 票 04:Agent 浏览器自测(Playwright)v2。 v2 修复:v1 的 networkidle 等待在 file:// + 跨域 fetch 下提前触发导致异步渲染未完成即断言; 改为显式条件等待(面板无「加载中」/ 阶段节点就位 / whoBox 已登录)。 datetime-local fill 用分钟精度(带秒 Playwright 报 Malformed value)。 覆盖面:列表多视图 / 详情九子表 / 工作计划三形态+闭环 / 新建双路径 / 暂缓-恢复状态机 / 规则三族 / 用户切换。 截图存档 shots/accept-NN-场景.png;结果写 selftest-result.json。 """ import json import sys import time import datetime import re from pathlib import Path from playwright.sync_api import sync_playwright sys.stdout.reconfigure(encoding='utf-8') DEMO = 'file:///D:/code/crm-backend-matt/.scratch/opportunity-e2e/demo/index.html' SHOTS = Path('d:/code/crm-backend-matt/.scratch/opportunity-acceptance/shots') SHOTS.mkdir(parents=True, exist_ok=True) FULL_OPP = '749639078307168256' # e2e-A-推进-智慧园区二期:客户3 卡3 跟进4 WP_OPP = '749647653033213952' # demo-验收-新建闭环:st=2 五节点,wp 空 results = [] # [case, verdict, detail] page_errors = [] console_errors = [] def rec(case, ok, detail=''): results.append([case, 'PASS' if ok else 'FAIL', detail]) print(('PASS ' if ok else 'FAIL '), case, ('| ' + detail) if detail else '', flush=True) def shot(page, name): page.wait_for_timeout(200) page.screenshot(path=str(SHOTS / (name + '.png')), full_page=True) def poll(page, fn, timeout): """Python 侧轮询:fn(page) 返回 truthy 即通过;超时静默(由后续断言判 FAIL)。""" end = time.time() + timeout / 1000.0 while time.time() < end: try: if fn(page): return except Exception: pass page.wait_for_timeout(300) def wait_ready(page, timeout=9000): """等 listBody/subBox/detailBox/ruleListBox 无「加载中」且 boardBox 无 loading 态。""" def fn(pg): for i in ('listBody', 'subBox', 'detailBox', 'ruleListBox'): el = pg.locator('#' + i) if el.count() and '加载中' in (el.text_content() or ''): return False return pg.locator('#boardBox .loading').count() == 0 poll(page, fn, timeout) def wait_stage(page, timeout=9000): poll(page, lambda pg: pg.locator('#stageBar .node').count() > 0, timeout) def wait_logged(page, timeout=9000): poll(page, lambda pg: '已登录' in (pg.locator('#whoBox').text_content() or ''), timeout) def wait_expr(page, js_expr, timeout=12000): def fn(pg): try: return bool(pg.evaluate(js_expr)) except Exception: return False poll(page, fn, timeout) def wait_sub_has(page, text, timeout=12000): poll(page, lambda pg: pg.locator('#subBox').count() > 0 and text in (pg.locator('#subBox').text_content() or ''), timeout) def wait_sub_not(page, text, timeout=12000): poll(page, lambda pg: pg.locator('#subBox').count() > 0 and text not in (pg.locator('#subBox').text_content() or ''), timeout) def wait_badge(page, text, timeout=12000): def fn(pg): b = pg.locator('#detailBox .badge') return b.count() > 0 and (b.first.text_content() or '').strip() == text poll(page, fn, timeout) def last_toast(page): return (page.locator('#toast .toast-item').last.text_content() or '').strip() def dialog_open(page): return page.evaluate("document.getElementById('dlg').open") def dfoot_btn(page, text): return page.locator('#dlg .dfoot button', has_text=text).first with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={'width': 1440, 'height': 900}) page.on('pageerror', lambda e: page_errors.append(str(e))) page.on('console', lambda m: console_errors.append(m.text) if m.type == 'error' else None) page.on('dialog', lambda d: d.accept()) # wpDelete 的原生 confirm # ============ 启动 ============ page.goto(DEMO) wait_logged(page) wait_ready(page) who = page.locator('#whoBox').text_content() or '' rec('00-启动自动登录(管理员)', '已登录' in who, who.strip()) # ============ A 列表多视图 ============ rows = page.locator('#listBody tr.row') rec('01-列表-我负责的', rows.count() >= 0 and '共' in (page.locator('#listHint').text_content() or ''), (page.locator('#listHint').text_content() or '').strip()) shot(page, 'accept-01-列表-我负责的') page.locator('#viewBar button', has_text='管理(全量)').click() wait_ready(page) rec('02-列表-管理全量视图', rows.count() > 0 and '共' in (page.locator('#listHint').text_content() or ''), (page.locator('#listHint').text_content() or '').strip() + ' / ' + str(rows.count()) + ' 行') shot(page, 'accept-02-列表-管理全量') page.locator('#viewBar button', has_text='公海').click() wait_ready(page) rec('03-列表-公海视图', '共' in (page.locator('#listHint').text_content() or ''), (page.locator('#listHint').text_content() or '').strip()) shot(page, 'accept-03-列表-公海') page.locator('#viewBar button', has_text='管理(全量)').click() wait_ready(page) page.fill('#fKeyword', '智慧园区') page.locator('button', has_text='查询').first.click() wait_ready(page) names = [(page.locator('#listBody tr.row td').nth(i * 9).text_content() or '') for i in range(rows.count())] rec('04-列表-关键词筛选', rows.count() > 0 and all('智慧园区' in n for n in names), str(rows.count()) + ' 行全含关键词') shot(page, 'accept-04-列表-关键词筛选') nodes = page.locator('#boardBox .node') rec('05-看板汇总', nodes.count() > 0, str(nodes.count()) + ' 个阶段节点') shot(page, 'accept-05-看板汇总') # ============ B 详情九子表(e2e-A-二期) ============ page.evaluate("loadDetail('" + FULL_OPP + "')") wait_ready(page) wait_stage(page) title = (page.locator('#detailBox h2').text_content() or '').strip() stage_nodes = page.locator('#stageBar .node') rec('06-详情总览-阶段进度5节点', '智慧园区' in title and stage_nodes.count() == 5, title + ' / 节点 ' + str(stage_nodes.count())) shot(page, 'accept-06-详情总览-阶段进度5节点') # 跟进子表(默认):toolbar 按钮在 = 20260829 修复点① 无回归 page.locator('#subTabs button', has_text='跟进').first.click() wait_ready(page) rec('07-子表-跟进-写跟进按钮在', page.locator('#subBox button', has_text='+ 写跟进').count() == 1, 'toolbar 渲染正常(修复点①)') # 写跟进闭环(修复点③:成功后 loadDetail 刷新最近有效跟进) before = page.evaluate("fetch('http://localhost:8080/api/opportunity/detail?id=" + FULL_OPP + "',{headers:{Authorization:'Bearer '+S.token}}).then(r=>r.json()).then(b=>b.data.lastValidFollowTime)") page.locator('#subBox button', has_text='+ 写跟进').click() page.wait_for_function("() => document.getElementById('dlg').open", timeout=5000) page.fill('#dlg [name="followContent"]', 'accept-自测跟进:票04浏览器自动化写入') cust_opts = page.locator('#dlg [name="customerId"] option') if cust_opts.count() > 0: cid = cust_opts.first.get_attribute('value') page.select_option('#dlg [name="customerId"]', cid) dfoot_btn(page, '保存').click() wait_ready(page) toast = last_toast(page) after = page.evaluate("fetch('http://localhost:8080/api/opportunity/detail?id=" + FULL_OPP + "',{headers:{Authorization:'Bearer '+S.token}}).then(r=>r.json()).then(b=>b.data.lastValidFollowTime)") today = datetime.date.today().isoformat() rec('08-写跟进-成功并刷新锚点(修复点③)', '跟进已写入' in toast and bool(after) and today in str(after), 'toast=' + toast + ' / lastValidFollowTime ' + str(before) + ' -> ' + str(after)) shot(page, 'accept-08-写跟进成功') else: rec('08-写跟进-成功并刷新锚点(修复点③)', False, '客户下拉为空,闭环未执行') if dialog_open(page): page.evaluate('closeDlg()') # 勘察子表(toolbar 修复点① 另一处) page.locator('#subTabs button', has_text='现场勘察').click() wait_ready(page) rec('09-子表-现场勘察', page.locator('#subBox button', has_text='+ 新增勘察').count() == 1, 'toolbar 渲染正常(修复点①)') shot(page, 'accept-09-子表-现场勘察') page.locator('#subTabs button', has_text='关联客户').click() wait_ready(page) rec('10-子表-关联客户', page.locator('#subBox .kv').count() >= 2, str(page.locator('#subBox .kv').count()) + ' 条') shot(page, 'accept-10-子表-关联客户') page.locator('#subTabs button', has_text='团队成员').click() wait_ready(page) rec('11-子表-团队成员', page.locator('#subBox .kv').count() >= 1, str(page.locator('#subBox .kv').count()) + ' 条') shot(page, 'accept-11-子表-团队成员') # 方案卡详情(修复点②:d.fields 键名渲染) page.locator('#subTabs button', has_text='方案卡').click() wait_ready(page) card_rows = page.locator('#subBox > div') if page.locator('#subBox button', has_text='详情').count() > 0: page.locator('#subBox button', has_text='详情').first.click() page.wait_for_function("() => document.getElementById('dlg').open", timeout=5000) kvs = page.locator('#dlg .grid .kv').count() rec('12-子表-方案卡详情(修复点②)', True, 'dialog 打开,字段键值 ' + str(kvs) + ' 项') shot(page, 'accept-12-方案卡详情') page.evaluate('closeDlg()') else: rec('12-子表-方案卡详情(修复点②)', False, '无方案卡') shot(page, 'accept-13-子表-方案卡列表') page.locator('#subTabs button', has_text='附件').click() wait_ready(page) rec('14-子表-附件', '加载中' not in (page.locator('#subBox').text_content() or ''), (page.locator('#subBox').text_content() or '')[:40]) shot(page, 'accept-14-子表-附件') # 操作日志(修复点④:分形态渲染,ROW_ADD=粗体「新增」) page.locator('#subTabs button', has_text='操作日志').click() wait_ready(page) oplog_html = page.locator('#subBox').inner_html() rec('15-子表-操作日志-分形态渲染(修复点④)', '新增' in oplog_html or 'FIELD_CHANGE' in oplog_html, '长度 ' + str(len(oplog_html))) shot(page, 'accept-15-子表-操作日志') page.locator('#subTabs button', has_text='阶段历史').click() wait_ready(page) rec('16-子表-阶段历史', '加载中' not in (page.locator('#subBox').text_content() or ''), (page.locator('#subBox').text_content() or '')[:40]) shot(page, 'accept-16-子表-阶段历史') # ============ C 工作计划三形态 + 闭环(WP_OPP) ============ page.evaluate("loadDetail('" + WP_OPP + "')") wait_ready(page) wait_stage(page) page.locator('#subTabs button', has_text='工作计划').click() wait_ready(page) rec('17-子表-工作计划入口', page.locator('#subBox button', has_text='+ 新增工作计划').count() == 1, '票03接入的四端点入口在') today = datetime.date.today() plans = [ ('accept-自测计划-逾期形态', (today - datetime.timedelta(days=1)).isoformat() + 'T10:00'), ('accept-自测计划-临期形态', (today + datetime.timedelta(days=2)).isoformat() + 'T10:00'), ('accept-自测计划-进行中形态', (today + datetime.timedelta(days=10)).isoformat() + 'T10:00'), ] for content, deadline in plans: page.locator('#subBox button', has_text='+ 新增工作计划').click() page.wait_for_function("() => document.getElementById('dlg').open", timeout=5000) page.fill('#dlg [name="planContent"]', content) page.fill('#dlg [name="deadline"]', deadline) dfoot_btn(page, '保存').click() wait_sub_has(page, content) # 新增行渲染出来才继续 wait_sub_has(page, 'accept-自测计划-进行中形态') # 等三行数据渲染(hint 常驻文本不可作判定) badges = page.locator('#subBox .badge') badge_texts = [(badges.nth(i).text_content() or '').strip() for i in range(badges.count())] ok3 = all(t in badge_texts for t in ('逾期', '临期', '进行中')) rec('18-工作计划-三形态实时判定', ok3, 'badge 文本集 ' + str(badge_texts)) shot(page, 'accept-17-工作计划三形态') # 登记完成(进行中那条) row = page.locator('#subBox > div', has_text='进行中形态').first row.locator('button', has_text='登记完成').click() wait_sub_has(page, '完成于') body = page.locator('#subBox').text_content() or '' rec('19-工作计划-登记完成回填finishTime', '已完成' in body and '完成于' in body, 'badge=已完成 + 完成时间显示') shot(page, 'accept-18-工作计划登记完成') # 编辑(临期那条改内容,部分更新) row = page.locator('#subBox > div', has_text='临期形态').first row.locator('button', has_text='编辑').click() page.wait_for_function("() => document.getElementById('dlg').open", timeout=5000) page.fill('#dlg [name="planContent"]', 'accept-自测计划-临期形态-已编辑') dfoot_btn(page, '保存').click() wait_sub_has(page, '已编辑') body = page.locator('#subBox').text_content() or '' rec('20-工作计划-编辑部分更新', '已编辑' in body, '内容已更新') shot(page, 'accept-19-工作计划编辑') # 删除(逾期那条,软删) row = page.locator('#subBox > div', has_text='逾期形态').first row.locator('button', has_text='删除').click() wait_sub_not(page, '逾期形态', timeout=25000) # 软删后列表不再可见(loadSub 刷新含远程库抖动余量) body = page.locator('#subBox').text_content() or '' rec('21-工作计划-删除软删', '逾期形态' not in body and '已编辑' in body, '逾期条从列表消失,其余保留') shot(page, 'accept-20-工作计划删除') # ============ D 新建双路径 ============ page.locator('header button', has_text='+ 新建商机').click() page.wait_for_function("() => [...document.querySelectorAll('#dlg [name=provinceCode] option')].some(o => o.value)", timeout=8000) page.locator('#dlg .dfoot button', has_text='创建').click() err = (page.locator('#crtErr').text_content() or '') need = ['商机名称', '商机来源', '省', '市', '项目属地', '招标形式'] rec('22-新建-六必填前端拦截', '必填缺失' in err and all(n in err for n in need), err.replace('\n', ' ')[:80]) shot(page, 'accept-21-新建-六必填拦截') ts = datetime.datetime.now().strftime('%H%M%S') new_name = 'accept-自测-新建' + ts page.fill('#dlg [name="opportunityName"]', new_name) src_val = page.evaluate("[...dlgEl.querySelectorAll('[name=oppSource] option')].find(o=>o.value && o.value!=='opp_source_01').value") page.select_option('#dlg [name="oppSource"]', src_val) prov_val = page.evaluate("[...dlgEl.querySelectorAll('[name=provinceCode] option')].find(o=>o.value).value") page.select_option('#dlg [name="provinceCode"]', prov_val) page.wait_for_function("() => [...document.querySelectorAll('#dlg [name=cityCode] option')].some(o => o.value)", timeout=8000) city_val = page.evaluate("[...dlgEl.querySelectorAll('[name=cityCode] option')].find(o=>o.value).value") page.select_option('#dlg [name="cityCode"]', city_val) page.evaluate("dlgEl.querySelector('[name=localityType]').value = dlgEl.querySelector('[name=localityType] option:nth-child(2)').value") page.evaluate("dlgEl.querySelector('[name=bidForm]').value = dlgEl.querySelector('[name=bidForm] option:nth-child(2)').value") page.locator('#dlg .dfoot button', has_text='创建').click() try: # toast 2.5s 自消失,先抓(成功=「商机已创建,id=...」;失败=err toast) page.wait_for_function("() => document.querySelectorAll('#toast .toast-item').length > 0", timeout=6000) except Exception: pass toast = last_toast(page) wait_expr(page, "() => (document.querySelector('#detailBox h2')||{textContent:''}).textContent.includes('" + new_name + "')", timeout=15000) wait_expr(page, "() => document.querySelectorAll('#stageBar .node').length === 5", timeout=15000) title = (page.locator('#detailBox h2').text_content() or '').strip() stage_nodes = page.locator('#stageBar .node') created = '商机已创建' in toast and new_name in title and stage_nodes.count() == 5 rec('23-新建-成功直达详情落首阶段(D-01)', created, 'toast=' + toast + ' / 节点 ' + str(stage_nodes.count())) shot(page, 'accept-22-新建-成功直达详情') # ============ E 状态机:暂缓 -> 恢复(在新建商机上闭环) ============ page.locator('#flowBar button', has_text=re.compile('^暂缓$')).click() page.wait_for_function("() => document.getElementById('dlg').open", timeout=5000) dhead = (page.locator('#dlg .dhead').text_content() or '') rec('24-暂缓对话框打开', '暂缓' in dhead, dhead.strip()) shot(page, 'accept-23-暂缓对话框') page.evaluate("dlgEl.querySelector('[name=pauseReason]').value = dlgEl.querySelector('[name=pauseReason] option:nth-child(2)').value") page.locator('#dlg .dfoot button', has_text=re.compile('^暂缓$')).click() wait_badge(page, '暂缓中') badge = (page.locator('#detailBox .badge').first.text_content() or '') rec('25-暂缓成功', badge == '暂缓中', 'badge=' + badge) shot(page, 'accept-24-暂缓成功') page.locator('#flowBar button', has_text=re.compile('^取消暂缓$')).click() wait_badge(page, '推进中') badge = (page.locator('#detailBox .badge').first.text_content() or '') rec('26-取消暂缓成功', badge == '推进中', 'badge=' + badge) shot(page, 'accept-25-恢复成功') # ============ F 规则三族 ============ page.locator('nav.tabs button', has_text='规则配置').click() wait_ready(page) rec('27-规则-公海与提醒', page.locator('#ruleListBox table').count() == 1, '默认族列表渲染') shot(page, 'accept-26-规则-公海与提醒') page.locator('#ruleBar button', has_text='商机阶段设置').click() wait_ready(page) rules_txt = page.locator('#ruleListBox').text_content() or '' rec('28-规则-商机阶段设置(预设模板在)', 'OPP_STAGE_TPL_01' in rules_txt, '预设模板保护验证点可见') shot(page, 'accept-27-规则-商机阶段设置') page.locator('#ruleBar button', has_text='方案卡模板').click() wait_ready(page) rec('29-规则-方案卡模板', page.locator('#ruleListBox table tr').count() >= 1, str(page.locator('#ruleListBox table tr').count()) + ' 行') shot(page, 'accept-28-规则-方案卡模板') page.locator('#ruleListBox tr.row').first.click() page.wait_for_function("() => document.getElementById('dlg').open", timeout=5000) rec('30-规则-详情对话框', True, (page.locator('#dlg .dhead').text_content() or '').strip()) shot(page, 'accept-29-规则详情') page.evaluate('closeDlg()') # ============ G 用户切换 ============ page.select_option('#userSel', '1') wait_logged(page) wait_ready(page) who = (page.locator('#whoBox').text_content() or '').strip() rec('31-切换用户A-数据权限视图', '赖永利' in who and '已登录' in who, who) shot(page, 'accept-30-切换用户A') browser.close() # ============ 汇总 ============ fails = [r for r in results if r[1] == 'FAIL'] print('\n==== 自测汇总: ' + str(len(results) - len(fails)) + '/' + str(len(results)) + ' PASS, ' + str(len(fails)) + ' FAIL ====') for f in fails: print('FAIL', f[0], f[2]) print('page_errors:', len(page_errors), page_errors[:5]) print('console_errors:', len(console_errors), console_errors[:5]) out = { 'summary': {'total': len(results), 'pass': len(results) - len(fails), 'fail': len(fails), 'pageErrors': len(page_errors), 'consoleErrors': len(console_errors)}, 'results': results, 'pageErrors': page_errors, 'consoleErrors': console_errors, } Path('d:/code/crm-backend-matt/.scratch/opportunity-acceptance/selftest-result.json').write_text( json.dumps(out, ensure_ascii=False, indent=2), encoding='utf-8') print('result -> selftest-result.json')