Files
group_xinghuo_jinrong/scripts/e2e/harness.mjs
T
zhanghongyu_0626 02a4f35c79 feat(tests): Conduct fourth round of end-to-end testing for customer trade functionality
- Completed 203 test cases with 162 passing, 6 failing, and 2 skipped, identifying 2 critical defects (P0: authentication privilege escalation, P1: suitability disclosure not enforced).
- Enhanced test coverage by verifying front-end rendering values against back-end raw responses, addressing previous gaps in validation.
- Documented findings and defects in the new test log and README files, ensuring clarity on test outcomes and areas for improvement.
- Updated the TODO list to reflect the current state of defects and testing priorities, emphasizing the need for immediate attention to identified issues.

This round of testing significantly improves the robustness of the customer trade functionality, ensuring compliance and security standards are met.
2026-09-13 18:51:52 +08:00

249 lines
9.1 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',
'/app/analytics/chat',
'/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 = {}) {
const { selector = '.ant-table-tbody tr', 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)
}
/** 把结果数组原样落盘,供证据包 `_raw/` 使用。 */
export function writeResults(fileName, payload) {
const p = path.join(OUT_DIR, fileName)
fs.writeFileSync(p, JSON.stringify(payload, null, 2), 'utf8')
return p
}