Files
group_xinghuo_jinrong/scripts/e2e/harness.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

281 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.
/**
* harness.mjs — 真浏览器 E2E 共用骨架(Playwright)
*
* 来源:从仓库外 `C:/Users/Windows/e2e-jinrong/harness.mjs` 收敛入库,
* 顺手还掉 `docs/memory/TODO.md` 的 P0「E2E 最小集进仓库」。
* 与仓库外版本的两处差异:
* 1) `ROUTES.customer` 补 `/app/customer/trade`(本轮新增页);
* 2) Playwright 由 `PW_HOME` 解析,**不往 `web/package.json` 加依赖**。
*
* 运行前置:Vite 必须已在 5173(app 无 CORS,浏览器不能直连 8000)。
* 用法:`node scripts/e2e/12-customer-trade.mjs`
* - `PW_HOME` Playwright 安装目录(默认 `C:/Users/Windows/e2e-jinrong`)
* - `E2E_BASE` 前端地址(默认 `http://localhost:5173`)
* - `E2E_SHOT_DIR` 截图目录(默认 `scripts/e2e/shots`)
* - `E2E_OUT_DIR` results JSON 目录(默认 `scripts/e2e`)
*/
import { createRequire } from 'node:module'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export const REPO_ROOT = path.resolve(__dirname, '..', '..')
// ── Playwright 解析:不新增仓库依赖 ────────────────────────────────
// createRequire 以 PW_HOME/package.json 为基准解析,于是命中它自己的 node_modules。
const PW_HOME = process.env.PW_HOME || 'C:/Users/Windows/e2e-jinrong'
const pwRequire = createRequire(path.join(PW_HOME, 'package.json'))
let chromium
try {
;({ chromium } = pwRequire('playwright'))
} catch (e) {
throw new Error(
`无法从 ${PW_HOME} 解析 playwright。请设 PW_HOME 指向已装 playwright 的目录。\n原始错误:${e.message}`,
)
}
export const BASE = process.env.E2E_BASE || 'http://localhost:5173'
export const SHOT_DIR = process.env.E2E_SHOT_DIR || path.join(__dirname, 'shots')
export const OUT_DIR = process.env.E2E_OUT_DIR || __dirname
fs.mkdirSync(SHOT_DIR, { recursive: true })
fs.mkdirSync(OUT_DIR, { recursive: true })
export const ACCOUNTS = {
customer: { actor: 'CUST-9527', home: '/app/customer/home' },
anotherCustomer: { actor: 'CUST-1002', home: '/app/customer/home' },
advisor: { actor: 'STAFF-10086', home: '/app/advisor/home' },
analyst: { actor: 'STAFF-20001', home: '/app/analyst/home' },
risk: { actor: 'STAFF-30001', home: '/app/risk/home' },
}
export const ROUTES = {
customer: [
'/app/customer/home',
'/app/customer/profile',
'/app/customer/holdings',
'/app/customer/trades',
'/app/customer/chat',
'/app/customer/trade', // 本轮新增:交易中心(申购/赎回/转换)
'/app/market',
'/app/analytics/query',
],
advisor: [
'/app/advisor/home',
'/app/advisor/customers',
'/app/advisor/compliance',
'/app/advisor/templates',
'/app/advisor/kyc',
'/app/advisor/market-alerts',
'/app/advisor/chat',
'/app/market',
'/app/analytics/query',
],
analyst: [
'/app/analyst/home',
'/app/analytics/query',
'/app/analytics/assets',
// 2026-09-13 移除 `/app/analytics/chat`:它不是页面,而是
// `web/src/App.tsx:137` 的 `<Navigate to="/app/analytics/query" replace/>`。
// 把它当页面做路由冒烟,只会验到一个重定向(永远"不报错"),属假绿。
'/app/market',
],
risk: [
'/app/risk/home',
'/app/risk/alerts',
'/app/risk/suitability',
'/app/risk/aml-scan',
'/app/risk/simulate',
'/app/risk/convert',
'/app/risk/chat',
'/app/market',
'/app/analytics/query',
],
}
/** Attach listeners that record everything a real user's browser would complain about. */
export function instrument(page) {
const rec = { console: [], pageErrors: [], httpErrors: [], failedRequests: [], responses: [] }
page.on('console', (msg) => {
const t = msg.type()
if (t === 'error' || t === 'warning') {
rec.console.push({ type: t, text: msg.text().slice(0, 500) })
}
})
page.on('pageerror', (err) => {
rec.pageErrors.push(String(err?.message ?? err).slice(0, 500))
})
page.on('response', (res) => {
const u = res.url()
rec.responses.push({ status: res.status(), url: u.replace(BASE, '') })
const s = res.status()
if (s >= 400) {
if (!u.startsWith(BASE)) return
rec.httpErrors.push({ status: s, url: u.replace(BASE, '') })
}
})
page.on('requestfailed', (req) => {
const u = req.url()
if (!u.startsWith(BASE)) return
rec.failedRequests.push({ url: u.replace(BASE, ''), err: req.failure()?.errorText })
})
return rec
}
export function summarize(rec) {
return {
pageErrors: [...new Set(rec.pageErrors)],
httpErrors: dedupe(rec.httpErrors, (e) => `${e.status} ${e.url}`),
failedRequests: dedupe(rec.failedRequests, (e) => e.url),
consoleErrors: dedupe(
rec.console.filter((c) => c.type === 'error'),
(c) => c.text,
),
consoleWarnings: dedupe(
rec.console.filter((c) => c.type === 'warning'),
(c) => c.text,
),
}
}
function dedupe(arr, keyFn) {
const seen = new Map()
for (const item of arr) {
const k = keyFn(item)
if (!seen.has(k)) seen.set(k, { ...item, count: 1 })
else seen.get(k).count += 1
}
return [...seen.values()]
}
export async function launch() {
return chromium.launch({ channel: 'chrome', headless: true })
}
/**
* Log in through the real login page.
*
* ⚠️ 2026-09-13 前端登录页已重写:原来的「一键 Demo 账号按钮」被**表单**取代
* (`web/src/config/demoAccounts.ts` 的 `DEMO_ACCOUNTS` 已置空并标 `@deprecated`,
* 改为账号输入框 + 密码框 + 登录按钮,右侧「答辩记号本」点行只负责填框)。
* 因此这里改为**填账号 → 点登录**。仓库外的 `09/10/11-*.mjs` 依赖旧版按钮式登录,
* 对当前前端已失效 —— 见测试报告「资产腐化」项。
*
* NOTE: the trailing reload is a WORKAROUND for BUG-1 (仓库外 FINDINGS.md). Without it the
* app crashes into the root ErrorBoundary on every first login. `reload:false` drives the
* regression probe, so at least one case runs the user's real path (form submit only).
*/
export async function login(page, accountKey, opts = {}) {
const { reload = true, settle = 2200 } = opts
await page.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
const account = ACCOUNTS[accountKey]
const input = page.locator('input#loginId, input[placeholder*="CUST-9527"]').first()
await input.waitFor({ state: 'visible', timeout: 15000 })
await input.fill('')
await input.fill(account.actor)
await page.locator('form button').filter({ hasText: '登录' }).first().click()
await page.waitForURL((u) => u.hash.includes(account.home), { timeout: 20000 })
if (reload) {
await page.reload({ waitUntil: 'domcontentloaded' }) // <- BUG-1 workaround
await page.waitForURL((u) => u.hash.includes(account.home), { timeout: 20000 })
}
await page.waitForTimeout(settle)
}
/**
* Drop the previous role's session. Without this, `#/login` immediately redirects back to
* the still-authenticated role's home and the next `login()` times out on the demo card.
*/
export async function clearAuth(page) {
await page.goto(`${BASE}/#/login`, { waitUntil: 'domcontentloaded' })
await page.evaluate(() => localStorage.clear())
await page.reload({ waitUntil: 'domcontentloaded' })
await page.waitForTimeout(600)
}
/**
* Poll until an AntD table actually has rows, instead of a fixed sleep that
* cannot tell "still loading" from "genuinely empty".
* NOTE: AntD 6 does not emit `.ant-table-row` — match the `tr` directly.
*/
export async function waitRows(page, opts = {}) {
// ⚠️ 2026-09-13 F-08a 修复:**必须排除 AntD 的空态占位行**。
// AntD 表格为空时并非 `.ant-table-tbody` 无 `tr`,而是渲染**一行**
// `<tr class="ant-table-placeholder"><td>暂无数据</td></tr>`
// 于是旧的 `selector = '.ant-table-tbody tr'` 会数到 1 →
// 「rows >= 1 / rows > 0 ⇒ 有数据」**恒假真**(false green)。
// 实测代价:`09-advisor-pages.mjs` A3.1 在表格实为「暂无数据」时报 rows=1,
// 骗过了 A3.3 的 `if (rows > 0)` 守卫 → 点不存在的「引用」按钮 → 30s 超时抛异常
// → **整支脚本中断**,A4/A5/A6 共 8 条用例全部丢失(17 PASS 掉到 9 PASS)。
// 故选择器统一加 `:not(.ant-table-placeholder)`,`min`/计数只针对真实数据行。
const {
selector = '.ant-table-tbody tr:not(.ant-table-placeholder)',
timeout = 12000,
min = 1,
} = opts
await page
.waitForFunction(({ selector, min }) => document.querySelectorAll(selector).length >= min, { selector, min }, { timeout })
.catch(() => {})
return page.locator(selector).count()
}
/** AntD inserts a space between adjacent CJK glyphs; match text through \s*. */
export function cjk(text) {
return new RegExp(text.split('').join('\\s*'))
}
/**
* Record `jr:trade-completed` dispatches into `window.__jrEvents`.
* 用于区分「真提交成功」与「被适当性拦下」——`TradeConfirmModal` 只在 `!data.blocked` 时派发。
* 注意:只在**非 reload** 的导航(hash 切换)间保留;reload 会重置为空数组。
*/
export async function installEventProbe(page) {
await page.addInitScript(() => {
window.__jrEvents = []
window.addEventListener('jr:trade-completed', () => {
window.__jrEvents.push({ at: Date.now() })
})
})
}
export const eventCount = (page) => page.evaluate(() => (window.__jrEvents || []).length)
/** 轮询等待某表达式为真;返回是否命中(不抛异常,便于把"没等到"写成断言而不是崩脚本)。 */
export async function until(page, fn, { timeout = 12000, step = 250, arg } = {}) {
const deadline = Date.now() + timeout
for (;;) {
const ok = await page.evaluate(fn, arg).catch(() => false)
if (ok) return true
if (Date.now() > deadline) return false
await page.waitForTimeout(step)
}
}
export function line(s) {
console.log(s)
}
/**
* F-13 证据保底:把 `out.push(x)` 换成 `emit(x)`,**边跑边出**。
*
* 动机(2026-09-13 实测):`03/04/06/07/08-*.mjs` 都把所有用例结果累积在 `out[]`,
* 只在**最后一行** `console.log(out.join('\n'))` 才打印。于是中途任何异常都会让
* **整轮已完成的结果全部丢失**。本轮 `08-edge.mjs` 真实发生过一次:
* 前 5 组已跑完,第 6 组登录时 Vite 抖动(`net::ERR_CONNECTION_REFUSED`,即计划里的 R7)
* → 抛异常 → 因为还没走到末尾那行,**前面所有 PASS/FAIL 一条都没留下**。
* 改为即时输出后,中断只会损失"中断点之后"的部分,已完成证据仍在 stdout 里。
*/
export function makeEmitter(out) {
return (line) => {
out.push(line)
console.log(line)
}
}
/** 把结果数组原样落盘,供证据包 `_raw/` 使用。 */
export function writeResults(fileName, payload) {
const p = path.join(OUT_DIR, fileName)
fs.writeFileSync(p, JSON.stringify(payload, null, 2), 'utf8')
return p
}