/** * 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 }