- 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.
281 lines
11 KiB
JavaScript
281 lines
11 KiB
JavaScript
/**
|
||
* 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
|
||
}
|