- 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.
188 lines
11 KiB
JavaScript
188 lines
11 KiB
JavaScript
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()
|