Files
group_xinghuo_jinrong/scripts/e2e/09-advisor-pages.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

384 lines
20 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.
/**
* 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()
}