Files
group_xinghuo_jinrong/scripts/e2e/08-edge.mjs
T
zhanghongyu_0626 188bf6a438 feat(analyst_agent): Add support for recent days queries and enhance SQL hint generation
- Introduced `_recent_days_series_hint` to handle queries related to "最近N天" for time series data aggregation.
- Added `_cn_num_to_int` function to convert Chinese numerals to integers for better query parsing.
- Updated `_nl_sql_hints` to incorporate the new hint generation logic, ensuring accurate SQL output for recent days queries.
- Enhanced documentation to reflect these changes and improve clarity on the new functionalities.
2026-09-13 22:36:19 +08:00

188 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { launch, instrument, login, BASE, SHOT_DIR, makeEmitter } from './harness.mjs'
const b = await launch()
const out = []
const emit = makeEmitter(out) // F-13:边跑边出,中途异常不丢已完成证据
const ok = (n, pass, d = '') => emit(`${pass ? 'PASS' : 'FAIL'} | ${n}${d ? ' | ' + d : ''}`)
const bodyText = async (p) => (await p.locator('body').innerText()).replace(/\s+/g, ' ')
// ---------- 1. deep link with no session ----------
{
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
const p = await ctx.newPage()
await p.goto(`${BASE}/#/app/risk/alerts`, { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(3000)
ok('未登录访问受保护路由 -> 回登录页', p.url().includes('/login'), p.url().replace(BASE, ''))
// F-03 修复:原断言匹配「选择演示账号」—— 该文案属已被重写的旧登录页(一键 Demo 按钮
// 已被 账号输入框 + 登录按钮 取代,`web/src/config/demoAccounts.ts` 的 DEMO_ACCOUNTS 已置空)。
// 改为断言新版登录表单的**真实**标志:账号输入框 + 登录按钮。
const loginFormOk = await p.evaluate(() => {
const hasInput = !!document.querySelector('input#loginId, input[placeholder*="CUST-9527"]')
const hasBtn = [...document.querySelectorAll('form button')].some((b) => /登\s*录/.test(b.innerText || ''))
return { hasInput, hasBtn }
})
ok('未登录时新版登录表单正常渲染(账号框 + 登录按钮)',
loginFormOk.hasInput && loginFormOk.hasBtn, JSON.stringify(loginFormOk))
await ctx.close()
}
// ---------- 2. unknown route ----------
{
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
const p = await ctx.newPage()
await login(p, 'customer')
await p.goto(`${BASE}/#/app/this/does/not/exist`, { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(4000)
ok('未知路由重定向到角色首页', p.url().includes('/app/customer/home'), p.url().replace(BASE, ''))
ok('未知路由无崩溃', !(await bodyText(p)).includes('应用加载失败'), (await bodyText(p)).slice(0, 80))
await ctx.close()
}
// ---------- 3. double-click login (double submit) ----------
{
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
const p = await ctx.newPage()
const logins = []
p.on('request', (r) => {
if (r.url().includes('/api/auth/login')) logins.push(1)
})
await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(2000)
// F-03 修复:原用例 getByRole('button', { name: /客户 Demo/ }) 找的是**已被删除**的
// 一键 Demo 按钮 → waitFor 超时抛异常,脚本在下一行崩掉(本轮部署前即已如此)。
// 改为走新版真表单:填账号 → 连点「登录」按钮。
const input = p.locator('input#loginId, input[placeholder*="CUST-9527"]').first()
await input.waitFor({ state: 'visible', timeout: 15000 })
await input.fill('CUST-9527')
const btn = p.locator('form button').filter({ hasText: '登录' }).first()
await btn.click()
await btn.click({ force: true }).catch(() => {})
await btn.click({ force: true }).catch(() => {})
await p.waitForTimeout(4000)
ok('连点登录按钮只发一次请求', logins.length === 1, `${logins.length} 次 /api/auth/login`)
await ctx.close()
}
// ---------- 4. visitor chat widget on login page ----------
{
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
const p = await ctx.newPage()
const rec = instrument(p)
await p.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(3000)
await p.screenshot({ path: `${SHOT_DIR}/login.png`, fullPage: true })
const t = await bodyText(p)
// F-12 修复:原断言用 `/游客|访客|咨询|客服/.test(正文)` 判「登录页有游客咨询入口」——
// 该正则在当前登录页**恒真**:实测命中的是右侧「答辩记号本」的演示账号按钮文案
// "CUST-9527 主 Demo · 持仓/问数/**客服**"(全页命中 2 个 button,都是它)。
// ⇒ 属**假绿**断言;随后的 `.first()` 又恰好选中收起态里那个**不可见**的同名按钮
// (实测 box=`0x0`、父链含隐藏样式)→ click 等待可见 **30s 超时** → **整支脚本中断**。
// 事实(读 `VisitorChatWidget.tsx` + `LoginPage.tsx:136`):游客试聊是登录页上
// **常驻的内联卡片**(`<Surface>`),**没有**展开/收起触发器 —— 所以"点触发器"这一步
// 本身就是对不存在交互的假设。卡片标题「游客试聊」、输入框 `placeholder="输入试聊问题"`、
// 按钮「发送试聊」(`disabled={!draft.trim()}`)。
// ⇒ 改为直接定位卡片内元素;并补一条真实规则断言(空输入时按钮禁用)。
const hasWidget = await p.locator('text=游客试聊').count()
ok('登录页有游客试聊卡片', hasWidget > 0, `命中「游客试聊」${hasWidget} 处`)
const ta = p.locator('textarea[placeholder="输入试聊问题"]').first()
if (await ta.count()) {
await p.screenshot({ path: `${SHOT_DIR}/visitor-widget.png`, fullPage: true })
// F-12b:会话证据必须**限定在游客卡片内**取,不能取 `body` 全文。
// 旧写法取的是页面全文,于是
// ① `after !== vt` 只要页面**任何**文本变化(按钮 loading 文案、时间戳)就为真;
// ② 免责声明正则被登录页自身或右侧「答辩记号本」的文案满足。
// ⇒ 两条都属**弱证据**:实测把 detail 打出来是「答辩记号本」的账号清单
// ("CUST-1002 第二客户 · 归属/流水 理财师 STAFF-10086 …"),
// 与游客对话毫无关系,读者**无法据此判断对话是否真的发生过**。
// 会话区 = 卡片内的滚动容器(`VisitorChatWidget.tsx:65`,class `max-h-48`);
// assistant 气泡 = 该容器内 class 含 `mr-6` 的子 div
// (`:74-76`:role=user → `ml-6 bg-jr-ink/90 text-white`,assistant → `mr-6 bg-white`)。
const convo = p.locator('div.max-h-48').first()
const convoCount = await p.locator('div.max-h-48').count()
const sendBtn = p.locator('button').filter({ hasText: /发\s*送\s*试\s*聊/ }).first()
const emptyDisabled = await sendBtn.isDisabled().catch(() => null)
ok('游客试聊空输入时「发送试聊」禁用', emptyDisabled === true, `disabled=${emptyDisabled}`)
await ta.fill('你们有哪些产品?')
await p.waitForTimeout(300)
await sendBtn.click()
// 真 LLM:轮询「assistant 气泡出现」而非睡固定 25s —— 固定睡眠既慢,又会在响应稍慢时假 FAIL。
let bubbles = 0
for (let i = 0; i < 60; i++) {
bubbles = await convo.locator('div.mr-6').count().catch(() => 0)
if (bubbles > 0) break
await p.waitForTimeout(1000)
}
const convoText = (await convo.innerText().catch(() => '')).replace(/\s+/g, ' ')
await p.screenshot({ path: `${SHOT_DIR}/visitor-widget-reply.png`, fullPage: true })
ok('游客对话能收到回复(卡片会话区内出现 assistant 气泡)',
bubbles >= 1, `助手气泡=${bubbles} / 会话区容器=${convoCount} | 会话区=${convoText.slice(0, 300)}`)
// 会话区里必须同时有**本轮**的提问 —— 排除「读到上一轮残留」造成的假 PASS
ok('会话区含本轮提问原文', /你们有哪些产品/.test(convoText), `会话区=${convoText.slice(0, 200)}`)
// 免责声明是 **intent 门控**的:`visitor_service.py:249-251` 仅当
// `should_add_disclaimer(intent)` 为真才把 `RISK_DISCLAIMER` 追加进 reply。
// 故按 R2 纪律降级为 INFO —— 记录实测,不预设通过、不进 FAIL 分母。
emit(`INFO | 游客回复是否含风险提示 | ${/不构成投资建议|仅供参考|AI 生成/.test(convoText) ? '含' : '未含(intent 门控,visitor_service.py:249)'}`)
} else {
ok('游客试聊卡片有输入框', false, t.slice(0, 200))
}
ok('游客流程无未捕获异常', rec.pageErrors.length === 0, JSON.stringify([...new Set(rec.pageErrors)]))
await ctx.close()
}
// ---------- 5. alert handling modal ----------
{
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
const p = await ctx.newPage()
const rec = instrument(p)
await login(p, 'risk')
await p.goto(`${BASE}/#/app/risk/alerts`, { waitUntil: 'domcontentloaded' })
await p.waitForTimeout(6000)
// switch to pending so a 处置 button exists
await p.getByText('全部状态').click().catch(() => {})
await p.waitForTimeout(600)
const opt = p.locator('.ant-select-item-option', { hasText: '待审核' }).first()
if (await opt.count()) {
await opt.click()
await p.waitForTimeout(6000)
}
const handleBtn = p.locator('button').filter({ hasText: /处\s*置/ }).first()
if (await handleBtn.count()) {
await handleBtn.click()
await p.waitForTimeout(1500)
await p.screenshot({ path: `${SHOT_DIR}/alert-handle-modal.png`, fullPage: true })
const modalText = await p.locator('.ant-modal').innerText().catch(() => '')
ok('处置弹窗打开', /处置预警/.test(modalText), modalText.replace(/\n/g, ' | ').slice(0, 200))
await p.locator('.ant-modal button').filter({ hasText: /提交处置/ }).first().click()
await p.waitForTimeout(9000)
const after = await bodyText(p)
ok('提交处置后有反馈', !/处置预警 ALT/.test(after), after.slice(-240))
} else {
ok('待审核列表有「处置」按钮', false, (await bodyText(p)).slice(0, 300))
}
ok('处置流程无未捕获异常', rec.pageErrors.length === 0, JSON.stringify([...new Set(rec.pageErrors)]))
await ctx.close()
}
// ---------- 6. session ends: reload on every role home ----------
for (const role of ['customer', 'advisor', 'analyst', 'risk']) {
const ctx = await b.newContext({ viewport: { width: 1280, height: 900 } })
const p = await ctx.newPage()
await login(p, role)
await p.reload({ waitUntil: 'domcontentloaded' })
await p.waitForTimeout(6000)
const t = await bodyText(p)
// F-03 修复:原负向断言 `!t.includes('选择演示账号')` 在旧登录页被重写后**恒真**(该文案
// 已不存在),等于没有断言。改为断言工作台真的渲染出内容(非空且无崩溃文案)。
ok(`[${role}] 刷新后仍停留工作台且未崩溃`,
!t.includes('应用加载失败') && t.trim().length > 50, `len=${t.trim().length} ${t.slice(60, 150)}`)
await ctx.close()
}
// 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 =====`)
await b.close()