- 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.
384 lines
20 KiB
JavaScript
384 lines
20 KiB
JavaScript
/**
|
||
* 09-advisor-pages.mjs — 顾问前端四页真实浏览器 E2E(本轮第三轮新增)
|
||
*
|
||
* 覆盖 spec `docs/superpowers/specs/2026-09-12-advisor-agent-frontend-design.md` 定义的:
|
||
* /app/advisor/compliance 合规与复制(三 Tab)
|
||
* /app/advisor/templates 话术模板(检索 → 引用 Modal → use_id + match_type)
|
||
* /app/advisor/kyc KYC 采集(建会话 → 发送采集 → 完成)
|
||
* /app/advisor/market-alerts 市场异动(净值 → 扫描 → 解读 → 采纳)
|
||
*
|
||
* 预期为红的页面(D2 KYC 500 / D3 语义检索降级)**不是脚本失败**,是缺陷的前端取证,
|
||
* 照样记 FAIL 并把页面上的实际文案 / trace_id 落进记录。
|
||
*
|
||
* 前提:后端 127.0.0.1:8000 + Vite localhost:5173 均在跑(浏览器必须走 Vite,app/ 无 CORS)。
|
||
* 用法:node 09-advisor-pages.mjs
|
||
*/
|
||
// F-06b:结果 JSON 改走 harness 的 `OUT_DIR`(受 `E2E_OUT_DIR` 控制),
|
||
// 不再写 cwd —— 否则四个证据包的 results 全部堆在 `scripts/e2e/`,进不了各包 `_raw/`。
|
||
import { launch, instrument, summarize, login, clearAuth, waitRows, BASE, SHOT_DIR, cjk, writeResults } from './harness.mjs'
|
||
|
||
const results = []
|
||
const rec = (o) => {
|
||
results.push(o)
|
||
const tag = o.status === 'PASS' ? ' ok ' : o.status === 'FAIL' ? 'FAIL' : 'skip'
|
||
console.log(`[${tag}] ${o.id} ${o.name} | ${o.actual}`)
|
||
}
|
||
|
||
const browser = await launch()
|
||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||
const page = await ctx.newPage()
|
||
let tel = instrument(page)
|
||
|
||
/** Snapshot listener-counters so per-case noise can be attributed to the case that caused it. */
|
||
const mark = () => ({
|
||
console: tel.console.length,
|
||
pageErrors: tel.pageErrors.length,
|
||
httpErrors: tel.httpErrors.length,
|
||
})
|
||
const since = (m) =>
|
||
summarize({
|
||
console: tel.console.slice(m.console),
|
||
pageErrors: tel.pageErrors.slice(m.pageErrors),
|
||
httpErrors: tel.httpErrors.slice(m.httpErrors),
|
||
failedRequests: [],
|
||
})
|
||
|
||
async function goto(route, settle = 2600) {
|
||
await page.goto(`${BASE}/#${route}`, { waitUntil: 'domcontentloaded' })
|
||
await page.waitForTimeout(settle)
|
||
}
|
||
|
||
/** Text of the AntD `Result` title — this app only renders it on the error path. */
|
||
const errTitle = () => page.locator('.ant-result-title').first().innerText().catch(() => null)
|
||
|
||
async function loginFresh(key, opts = {}) {
|
||
await clearAuth(page)
|
||
await login(page, key, opts)
|
||
}
|
||
|
||
try {
|
||
await loginFresh('advisor')
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// A-1 · 首页(hero 指标 + 名册表;图表是 canvas,不断言图形)
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/advisor/home')
|
||
const rows = await waitRows(page)
|
||
const canvas = await page.locator('canvas').count()
|
||
const title = await errTitle()
|
||
const issues = since(m)
|
||
if (title) {
|
||
rec({ id: 'A1.1', name: '首页渲染', status: 'FAIL', expected: '无 ErrorBoundary', actual: `errTitle=${title}`, issues })
|
||
} else if (rows < 1) {
|
||
rec({ id: 'A1.1', name: '首页名册表有行', status: 'FAIL', expected: 'rows>0', actual: `rows=${rows}`, issues })
|
||
} else {
|
||
rec({ id: 'A1.1', name: '首页渲染 + 名册表有行', status: 'PASS', expected: 'rows>0', actual: `rows=${rows} canvas=${canvas}`, issues })
|
||
}
|
||
if (issues.httpErrors.length) {
|
||
rec({ id: 'A1.2', name: '首页无 HTTP>=400', status: 'FAIL', expected: '0 条', actual: JSON.stringify(issues.httpErrors.slice(0, 4)), issues })
|
||
} else {
|
||
rec({ id: 'A1.2', name: '首页无 HTTP>=400', status: 'PASS', expected: '0 条', actual: '0 条', issues })
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// A-2 · 合规与复制(三 Tab)
|
||
// ════════════════════════════════════════════════════════════
|
||
await goto('/app/advisor/compliance', 2000)
|
||
|
||
// AntD keeps inactive Tab panels MOUNTED (hidden), so `.ant-select` unscoped resolves to
|
||
// Tab-1's hidden control. Every interaction below is scoped to the active pane.
|
||
// AntD 6 pitfall: the active pane is `.ant-tabs-content-active` — there is NO
|
||
// `.ant-tabs-tabpane` / `.ant-tabs-tabpane-active` in this version.
|
||
const pane = () => page.locator('.ant-tabs-content-active')
|
||
|
||
/** Pick an AntD Select option inside the active pane (options portal to a dropdown). */
|
||
async function pickOption(label) {
|
||
await pane().locator('.ant-select').first().click()
|
||
await page.waitForTimeout(700)
|
||
const opt = page.locator('.ant-select-item-option, [role="option"]').filter({ hasText: label })
|
||
await opt.last().click()
|
||
await page.waitForTimeout(500)
|
||
}
|
||
|
||
// Tab 1 合规检测:预置「违规承诺」→ 期望 BLOCK
|
||
{
|
||
const m = mark()
|
||
await page.getByRole('tab', { name: cjk('合规检测') }).click().catch(() => {})
|
||
await page.waitForTimeout(1200)
|
||
await pickOption('违规承诺')
|
||
await pane().getByRole('button', { name: cjk('检测') }).click()
|
||
await page.waitForTimeout(3200)
|
||
const t = (await pane().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const passed = /BLOCK/.test(t)
|
||
rec({
|
||
id: 'A2.1', name: '合规检测 违规承诺 → BLOCK',
|
||
status: passed ? 'PASS' : 'FAIL',
|
||
expected: '结果含 risk_level=BLOCK', actual: passed ? '命中 BLOCK' : `未命中,面板片段: ${t.slice(0, 220)}`,
|
||
issues: since(m),
|
||
})
|
||
}
|
||
|
||
// Tab 2 复制门闸:**有序依赖** —— runCompliance() 里 copyForm.setFieldsValue(...) 自动回填本 Tab 的
|
||
// 表单,且提交时带 complianceResult?.check_id。所以「Tab1 未先跑 → 本 Tab 表单为空」是设计行为。
|
||
{
|
||
const m = mark()
|
||
await page.getByRole('tab', { name: cjk('复制门闸') }).click().catch(() => {})
|
||
await page.waitForTimeout(1400)
|
||
const summary = await pane().locator('#content_summary').inputValue().catch(() => '')
|
||
// AntD 6: select value renders in `.ant-select-content` (was `.ant-select-selection-item`).
|
||
const level = (await pane().locator('.ant-select-content').first().innerText().catch(() => '')).trim()
|
||
const prefilled = summary.length > 0
|
||
rec({
|
||
id: 'A2.2', name: '复制门闸 Tab1 检测后自动回填(有序依赖)',
|
||
status: prefilled ? 'PASS' : 'FAIL',
|
||
expected: '内容摘要非空(由 Tab1 的 check 回填)',
|
||
actual: `summary=${JSON.stringify(summary.slice(0, 40))} level=${level}`,
|
||
issues: since(m),
|
||
})
|
||
// BLOCK 级内容**就应当被门闸拒绝** —— 这是设计行为,不是失败。
|
||
await pane().getByRole('button', { name: cjk('提交复制追踪') }).click()
|
||
await page.waitForTimeout(3500)
|
||
let t = (await pane().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const refused = /cannot be copied or exported|BLOCK/.test(t) && !/use_id/.test(t)
|
||
rec({
|
||
id: 'A2.3', name: '复制门闸 BLOCK 内容 → 拒绝导出',
|
||
status: refused ? 'PASS' : 'FAIL',
|
||
expected: 'BLOCK 级内容被门闸拒绝(content cannot be copied or exported)',
|
||
actual: refused ? '已拒绝导出' : `面板片段: ${t.slice(0, 260)}`,
|
||
issues: since(m),
|
||
})
|
||
|
||
// 反向:安全文案(INFO 级)应放行 —— 回到 Tab1 重跑,再回 Tab2 提交。
|
||
await page.getByRole('tab', { name: cjk('合规检测') }).click().catch(() => {})
|
||
await page.waitForTimeout(1200)
|
||
await pickOption('安全说明')
|
||
await pane().getByRole('button', { name: cjk('检测') }).click()
|
||
await page.waitForTimeout(3200)
|
||
await page.getByRole('tab', { name: cjk('复制门闸') }).click().catch(() => {})
|
||
await page.waitForTimeout(1400)
|
||
await pane().getByRole('button', { name: cjk('提交复制追踪') }).click()
|
||
await page.waitForTimeout(3500)
|
||
t = (await pane().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const okTrack = /use_id|copy_id|track/i.test(t) && !/cannot be copied/.test(t)
|
||
rec({
|
||
id: 'A2.4', name: '复制门闸 INFO 内容 → 放行并出 use_id',
|
||
status: okTrack ? 'PASS' : 'FAIL',
|
||
expected: '追踪成功(返回 use_id/copy_id 回执)',
|
||
actual: okTrack ? t.match(/(use_id|copy_id)["':\s]*[A-Za-z0-9_-]+/)?.[0] ?? '回执出现' : `面板片段: ${t.slice(0, 260)}`,
|
||
issues: since(m),
|
||
})
|
||
}
|
||
|
||
// Tab 3 输入防护:注入样例 → guard_type=prompt_injection
|
||
{
|
||
const m = mark()
|
||
await page.getByRole('tab', { name: cjk('输入防护') }).click().catch(() => {})
|
||
await page.waitForTimeout(1200)
|
||
await pickOption('注入样例')
|
||
await pane().getByRole('button', { name: cjk('检测') }).click()
|
||
await page.waitForTimeout(3200)
|
||
const t = (await pane().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
// 后端 guard_type=prompt_injection(API 侧 R7/G7 亦断言过);前端文案写作
|
||
// "Input contains prompt injection probe",故同时接受下划线与空格两种写法。
|
||
const hit = /prompt[_ ]?injection/i.test(t) || /40002/.test(t)
|
||
rec({
|
||
id: 'A2.5', name: '输入防护 注入样例 → prompt_injection',
|
||
status: hit ? 'PASS' : 'FAIL',
|
||
expected: 'guard_type=prompt_injection / code 40002',
|
||
actual: hit ? `命中:${t.match(/[^ ]*prompt[_ ]?injection[^ ]*/i)?.[0] ?? '40002'}` : `未命中,面板片段: ${t.slice(0, 240)}`,
|
||
issues: since(m),
|
||
})
|
||
}
|
||
|
||
await page.screenshot({ path: `${SHOT_DIR}/09-advisor-compliance.png`, fullPage: true })
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// A-3 · 话术模板(关键:记录 match_type 给 D3 实测定性)
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/advisor/templates')
|
||
await page.getByPlaceholder('搜索关键词').fill('亏损')
|
||
await page.getByRole('button', { name: cjk('检索') }).click()
|
||
await page.waitForTimeout(3200)
|
||
const rows = await waitRows(page)
|
||
if (rows < 1) {
|
||
rec({ id: 'A3.1', name: '话术模板 搜「亏损」有行', status: 'FAIL', expected: 'rows>0', actual: `rows=0(模板未灌?)`, issues: since(m) })
|
||
} else {
|
||
rec({ id: 'A3.1', name: '话术模板 搜「亏损」有行', status: 'PASS', expected: 'rows>0', actual: `rows=${rows}`, issues: since(m) })
|
||
}
|
||
// match_type 列(第 3 列)
|
||
const matchTypes = await page.locator('.ant-table-tbody tr').evaluateAll((trs) =>
|
||
trs.map((tr) => tr.children[2]?.textContent?.trim()).filter(Boolean),
|
||
)
|
||
const onlyKeyword = matchTypes.length > 0 && matchTypes.every((t) => t === 'keyword')
|
||
rec({
|
||
id: 'A3.2', name: '话术模板 match_type(D3 定性)',
|
||
status: onlyKeyword ? 'FAIL' : 'PASS',
|
||
expected: '含 semantic/hybrid',
|
||
actual: `match_type=${JSON.stringify(matchTypes)}(若全为 keyword 则语义检索未生效)`,
|
||
issues: since(m),
|
||
})
|
||
// 引用 Modal → OK → use_id
|
||
if (rows > 0) {
|
||
await page.getByRole('button', { name: cjk('引用') }).first().click()
|
||
await page.waitForTimeout(1200)
|
||
await page.locator('.ant-modal-footer button.ant-btn-primary').click()
|
||
await page.waitForTimeout(3000)
|
||
const t = (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const hit = /use_id/.test(t)
|
||
rec({
|
||
id: 'A3.3', name: '话术模板 引用 → use_id',
|
||
status: hit ? 'PASS' : 'FAIL',
|
||
expected: '页面出现 use_id',
|
||
actual: hit ? (t.match(/use_id\s*=\s*\S+/)?.[0] ?? 'use_id 出现') : `未出现: ${t.slice(0, 200)}`,
|
||
issues: since(m),
|
||
})
|
||
}
|
||
await page.screenshot({ path: `${SHOT_DIR}/09-advisor-templates.png`, fullPage: true })
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// A-4 · KYC 采集(预期红:D2 created_at 无 default → 500)
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/advisor/kyc')
|
||
// 建会话
|
||
await page.getByRole('button', { name: cjk('创建会话') }).click()
|
||
await page.waitForTimeout(3500)
|
||
const t1 = (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const sessionOk = /session_id|会话状态|进行中/.test(t1) && !/请求失败|服务异常/.test(t1)
|
||
rec({
|
||
id: 'A4.1', name: 'KYC 创建会话',
|
||
status: sessionOk ? 'PASS' : 'FAIL',
|
||
expected: '创建成功(会话状态卡片出现)',
|
||
actual: sessionOk ? '创建成功' : `页面片段: ${t1.slice(0, 220)}`,
|
||
issues: since(m),
|
||
})
|
||
// 发送采集 —— 预期 500(D2)
|
||
await page.getByRole('button', { name: cjk('发送采集') }).click().catch(() => {})
|
||
await page.waitForTimeout(5000)
|
||
// 取证:把 ApiErrorResult 整块文案(含 error_code / trace_id)抓下来,供报告引用。
|
||
const errBody = (await page.locator('.ant-result').first().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const trace = errBody.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i)?.[1] ?? null
|
||
const hasErr = /错误|失败|异常|ERROR/.test(errBody)
|
||
rec({
|
||
id: 'A4.2', name: 'KYC 发送采集(D2 取证)',
|
||
status: hasErr ? 'FAIL' : 'PASS',
|
||
expected: '200 且 parsed_fields 非空 progress_pct>0',
|
||
actual: hasErr ? `页面报错「${errBody.slice(0, 200)}」${trace ? ` trace_id=${trace}` : ''}` : '未报错',
|
||
issues: since(m),
|
||
})
|
||
await page.screenshot({ path: `${SHOT_DIR}/09-advisor-kyc.png`, fullPage: true })
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// A-5 · 市场异动(D1 已修 → 本轮预期转绿)
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
const m = mark()
|
||
await goto('/app/advisor/market-alerts')
|
||
await page.getByRole('button', { name: cjk('查 000001 净值') }).click()
|
||
await page.waitForTimeout(3500)
|
||
let t = (await page.locator('body').innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
const quoteOk = /nav_date|daily_return/.test(t)
|
||
rec({
|
||
id: 'A5.1', name: '市场异动 查净值 000001(D1 回归)',
|
||
status: quoteOk ? 'PASS' : 'FAIL',
|
||
expected: '返回 nav / nav_date', actual: quoteOk ? '净值快照渲染' : `片段: ${t.slice(0, 220)}`,
|
||
issues: since(m),
|
||
})
|
||
|
||
await page.getByRole('button', { name: cjk('扫描') }).click()
|
||
await page.waitForTimeout(4000)
|
||
await page.getByRole('button', { name: cjk('刷新列表') }).click().catch(() => {})
|
||
await page.waitForTimeout(3000)
|
||
const rows = await waitRows(page)
|
||
rec({
|
||
id: 'A5.2', name: '市场异动 扫描 + 列表',
|
||
status: rows > 0 ? 'PASS' : 'FAIL',
|
||
expected: '列表有异动行', actual: `rows=${rows}`,
|
||
issues: since(m),
|
||
})
|
||
|
||
if (rows > 0) {
|
||
await page.getByRole('button', { name: cjk('生成解读') }).first().click()
|
||
// 生成走真实 LLM,等结果本身而不是等固定时长(API 侧实测 latency≈1.4s,前端留足余量)。
|
||
await page.locator('pre').first().waitFor({ state: 'visible', timeout: 45000 }).catch(() => {})
|
||
await page.waitForTimeout(800)
|
||
// 注意:解读结果是**内联 <div>**(AdvisorMarketAlertsPage.tsx:174-185),不是 Modal。
|
||
const gen = (await page.locator('pre').first().innerText().catch(() => '')).replace(/\s+/g, ' ')
|
||
// 与 API 侧 advisor_e2e_smoke.py:616 用同一组段落标记,勿自创。
|
||
const SECTIONS = ['【异动概述】', '【原因分析】', '【当前建议】', '【风险提示】']
|
||
const segs = SECTIONS.filter((s) => gen.includes(s))
|
||
rec({
|
||
id: 'A5.3', name: '市场异动 生成解读 → 四段式',
|
||
status: segs.length === SECTIONS.length ? 'PASS' : 'FAIL',
|
||
expected: '含四个必备段落【异动概述】【原因分析】【当前建议】【风险提示】',
|
||
actual: `命中段=${JSON.stringify(segs)} len=${gen.length}${gen ? '' : '(文案为空)'}`,
|
||
issues: since(m),
|
||
})
|
||
|
||
// 采纳:以**真实 HTTP 响应**为准,不靠 body 文案(采纳按钮文案本身含 "adopt",会假过)。
|
||
// feedbackMarketAlert 走的是 PUT(advisorAgent.ts:196-200),不是 POST。
|
||
const waitFb = page
|
||
.waitForResponse((res) => /\/market-alerts\/[^/]+\/feedback/.test(res.url()), { timeout: 30000 })
|
||
.catch(() => null)
|
||
await page.getByRole('button', { name: cjk('采纳') }).click().catch(() => {})
|
||
const fb = await waitFb
|
||
const fbStatus = fb?.status() ?? null
|
||
rec({
|
||
id: 'A5.4', name: '市场异动 采纳(adopt)',
|
||
status: fbStatus === 200 ? 'PASS' : 'FAIL',
|
||
expected: 'PUT /market-alerts/{id}/feedback → HTTP 200',
|
||
actual: fbStatus === null ? '未捕获到 feedback 请求(按钮可能未渲染)' : `HTTP ${fbStatus}`,
|
||
issues: since(m),
|
||
})
|
||
}
|
||
await page.screenshot({ path: `${SHOT_DIR}/09-advisor-market-alerts.png`, fullPage: true })
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// A-6 · BUG-1 回归:去掉 reload 后仍能登录
|
||
// ════════════════════════════════════════════════════════════
|
||
{
|
||
await clearAuth(page)
|
||
let bug1 = 'PASS'
|
||
let detail = '未出现 ErrorBoundary'
|
||
try {
|
||
await login(page, 'advisor', { reload: false, settle: 3000 })
|
||
const title = await errTitle()
|
||
const hash = new URL(page.url()).hash
|
||
if (title || !hash.includes('/app/advisor/home')) {
|
||
bug1 = 'FAIL'
|
||
detail = `errTitle=${title ?? '-'} hash=${hash}`
|
||
}
|
||
} catch (e) {
|
||
bug1 = 'FAIL'
|
||
detail = `登录超时/异常: ${e.message}`
|
||
}
|
||
rec({
|
||
id: 'A6.1', name: 'BUG-1 回归:点击登录(无 reload 兜底)',
|
||
status: bug1, expected: '直接进入 /app/advisor/home 且无 ErrorBoundary', actual: detail, issues: {},
|
||
})
|
||
}
|
||
} catch (e) {
|
||
rec({ id: 'SCRIPT', name: '脚本异常', status: 'FAIL', expected: '正常跑完', actual: e.message, issues: {} })
|
||
} finally {
|
||
const counts = {
|
||
PASS: results.filter((r) => r.status === 'PASS').length,
|
||
FAIL: results.filter((r) => r.status === 'FAIL').length,
|
||
SKIP: results.filter((r) => r.status === 'SKIP').length,
|
||
}
|
||
writeResults('results-09-advisor.json', { counts, results })
|
||
console.log(`\n===== 09 顾问前端: ${counts.PASS} PASS / ${counts.FAIL} FAIL / ${counts.SKIP} SKIP =====`)
|
||
for (const r of results) {
|
||
if (r.status === 'FAIL') console.log(` FAIL ${r.id} ${r.name} :: ${r.actual}`)
|
||
}
|
||
await browser.close()
|
||
}
|