import { launch, instrument, login, BASE, SHOT_DIR, makeEmitter } from './harness.mjs' const b = await launch() const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } }) const p = await ctx.newPage() const rec = instrument(p) const out = [] const emit = makeEmitter(out) // F-13:边跑边出,中途异常不丢已完成证据 const ok = (n, pass, d = '') => emit(`${pass ? 'PASS' : 'FAIL'} | ${n}${d ? ' | ' + d : ''}`) const bodyText = async () => (await p.locator('body').innerText()).replace(/\s+/g, ' ') /** * 角色 → 对话页真实路径。 * * 修复前:`#/app/${role}/chat` 直接拼接 —— 对 `analyst` 拼出不存在的 `/app/analyst/chat` * (2026-09-13 记录为资产腐化 F-02)。 * 关键事实(读 `web/src/App.tsx` 与 `web/src/routes/menus.tsx` 得): * 只有 customer / advisor / risk **三个**角色有对话页; * `/app/analytics/chat` **不是页面**,而是 `` * —— 数据分析线的问答入口就是「问数工作台」(`/app/analytics/query`)。 * 故此处不是"把 analyst 换写成 analytics",而是**承认数据分析线没有独立对话页**。 */ const CHAT_PATH = { customer: '/app/customer/chat', advisor: '/app/advisor/chat', risk: '/app/risk/chat', // 数据分析线无对话页:走问数工作台(Q&A 工作台,非流式对话) analyst: '/app/analytics/query', } async function sendChat(role, message, { stream = true } = {}) { const path = CHAT_PATH[role] if (!path) throw new Error(`06-chat.mjs 未定义角色 ${role} 的对话页路径`) await p.goto(`${BASE}/#${path}`, { waitUntil: 'domcontentloaded' }) await p.waitForTimeout(6000) const before = await bodyText() const ta = p.locator('textarea').first() if (!(await ta.count())) return { ok: false, reason: 'no textarea', before } await ta.fill(message) // find the send control const send = p .locator('button') .filter({ hasText: /发送|提交|发送消息/ }) .first() if (await send.count()) await send.click() else await ta.press('Enter') await p.waitForTimeout(25000) const after = await bodyText() return { ok: after !== before, before, after } } // ================= RISK CHAT (SSE stream) ================= await login(p, 'risk') const r1 = await sendChat('risk', '现在有多少条待处理预警?') await p.screenshot({ path: `${SHOT_DIR}/chat-risk.png`, fullPage: true }) ok('风控助手能发出消息并收到回复', r1.ok, (r1.after ?? r1.before).slice(-380)) // 保持**硬断言**(本轮实跑 PASS)。但如实标注其确定性来源,勿误当"后端强制兜底": // 实测命中的「本回复仅供参考,不构成投资建议。市场有风险,投资需谨慎。」**既不等于** // `RISK_DISCLAIMER`(`compliance_guard.py:26`「【风险提示】以上信息仅供参考…」) // **也不等于** `CHAT_DISCLAIMER`(`agent_service.py:40`「以上内容由 AI 生成…」) // ⇒ 它来自风控 Agent 的**系统提示词**,属"模型听话"而非"代码门控"。 // 与客户线(F-14)属同一类风险,只是当前措辞稳定。若哪天提示词改动使其漂移, // 这条会 FAIL —— 届时按 F-14 的口径先取证 intent/提示词来源,再决定降级还是记缺陷。 ok('风控回复带免责声明', /不构成投资建议|仅供参考|AI 生成/.test(r1.after ?? ''), (r1.after ?? '').slice(-200)) // session list / new session / close const sessText = await bodyText() // ================= ADVISOR CHAT ================= await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' }) await p.evaluate(() => localStorage.clear()) await p.reload({ waitUntil: 'domcontentloaded' }) await login(p, 'advisor') const r2 = await sendChat('advisor', 'CUST-9527 现在的持仓情况如何?') await p.screenshot({ path: `${SHOT_DIR}/chat-advisor.png`, fullPage: true }) ok('顾问助手能发出消息并收到回复', r2.ok, (r2.after ?? r2.before).slice(-380)) // ================= ANALYST(数据分析线)================= // ⚠️ 本组**不是**对话用例:数据分析线没有独立对话页(见 CHAT_PATH 注释)。 // 原用例 `sendChat('analyst', '你好')` 打的是不存在的 /app/analyst/chat,属 F-02 腐化。 // 改为验证该线的**真实**问答入口「问数工作台」可提交并拿到回答。 await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' }) await p.evaluate(() => localStorage.clear()) await p.reload({ waitUntil: 'domcontentloaded' }) await login(p, 'analyst') const r3 = await sendChat('analyst', '平台客户总数是多少') await p.screenshot({ path: `${SHOT_DIR}/analytics-query.png`, fullPage: true }) ok('问数工作台能提交问题并拿到回答', r3.ok, (r3.after ?? r3.before).slice(-380)) // ================= CUSTOMER CHAT (sync) ================= await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' }) await p.evaluate(() => localStorage.clear()) await p.reload({ waitUntil: 'domcontentloaded' }) await login(p, 'customer') const r4 = await sendChat('customer', '我的持仓收益怎么样?') await p.screenshot({ path: `${SHOT_DIR}/chat-customer.png`, fullPage: true }) ok('客户助手能发出消息并收到回复', r4.ok, (r4.after ?? r4.before).slice(-380)) // F-14 修复(本脚本此前**从未跑过**,故该断言从未被校准): // 原写法 `ok('客户回复带免责声明', /不构成投资建议|仅供参考|AI 生成/.test(...))` 是**硬断言**, // 本轮首跑 FAIL。取证后确认**不是产品缺陷,是断言越界**,两条独立理由: // ① 客户线的免责声明是 **intent 门控**的:`customer_service.py:405-407` 只在 // `should_add_disclaimer(intent)` 为真时才把 `RISK_DISCLAIMER` 逐字追加,而 // `compliance_guard.py:108-110` 的名单只有 `("product_consult","policy_interpret","faq")` // —— 「我的持仓收益怎么样?」解析出的持仓类意图**不在名单内**,按设计不追加。 // (与 08-edge.mjs 的游客试聊是**同一个**设计事实,那边已按 R2 降级为 INFO。) // ② 断言用的正则是**逐字**匹配。实测本轮回复含 LLM 自撰的 // 「不构成任何买卖建议」——**实质性风险提示在,只是措辞不同**, // 逐字正则在真 LLM 输出上必然时过时不过(R2:LLM 语义一律软探针)。 // 故拆成两条 INFO(记录实测,不进 FAIL 分母),且**证据比原来更细**: // 分别记录「实质性提示有无」与「规范文案有无」,后者才是确定性路径的证据。 // ⚠️ 遗留的**产品口径问题**(不因本次降级而消失):返回收益数字的回复是否**应当** // 强制带规范风险提示,记入横切包 XC 待拍板 —— 本轮只取证不修。 const custTail = (r4.after ?? '') const custSubstantive = /不构成[^。]{0,12}建议|仅供参考|风险提示|AI 生成/.test(custTail) const custCanonical = custTail.includes('不构成投资建议') // RISK_DISCLAIMER 逐字 emit(`INFO | 客户回复含实质性风险提示 | ${custSubstantive ? '含' : '未含'}(LLM 自撰措辞也算,如「不构成任何买卖建议」)`) emit(`INFO | 客户回复含**规范**风险提示(RISK_DISCLAIMER 逐字) | ${custCanonical ? '含' : '未含(intent 门控:持仓类意图不在 compliance_guard.py:109 名单内)'}`) // empty message must not send { const before = await bodyText() await p.locator('textarea').first().fill(' ') await p.waitForTimeout(800) const send = p.locator('button').filter({ hasText: /发送/ }).first() const disabled = (await send.count()) ? await send.isDisabled() : false ok('空白输入时发送按钮被禁用', disabled, disabled ? 'disabled' : 'still clickable') ok('空白消息未发出', (await bodyText()) === before) } // F-13:明细已在产生时即时输出;此处只给汇总(即使中断也不丢已完成证据) const _nP = out.filter((l) => l.startsWith('PASS')).length const _nF = out.filter((l) => l.startsWith('FAIL')).length const _nI = out.filter((l) => l.startsWith('INFO')).length console.log(` ===== ${_nP} PASS / ${_nF} FAIL / ${_nI} INFO =====`) console.log('\n--- errors ---') console.log(JSON.stringify({ pageErrors: [...new Set(rec.pageErrors)], httpErrors: rec.httpErrors.slice(0, 10) })) await b.close()