const { chromium } = require("playwright-core"); const BASE = process.env.QA_BASE_URL || "http://127.0.0.1:5175/"; const SHOTS = process.env.QA_SHOTS || "/tmp/opencode/fund-lab-qa-screens"; const EXE = "/home/somhairle/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome"; const results = []; const consoleErrors = []; const fundPosts = []; const orderPosts = []; const orderPostStatuses = []; const confirmPosts = []; const confirmPostStatuses = []; const redemptionPosts = []; const redemptionConfirmResponses = []; const capitalPosts = []; const capitalPostStatuses = []; let positionGets = 0; let lastPositionsResponse = null; let fundPostResponses = 0; let fundGets = 0; let orderGets = 0; let createdFund = null; let createdFundResponse = null; let lastOrdersResponse = null; let lastConfirmResponse = null; let lastRedemptionConfirmResponse = null; let lastReturnsResponse = null; let lastPlanRunResponse = null; let lastPlansResponse = null; let lastRebalancePlansResponse = null; let lastRebalancePreviewResponse = null; let lastRebalanceExecuteResponse = null; let lastRebalanceExecutionsResponse = null; let lastNavDatesResponse = null; let lastNavSeriesResponse = null; let lastBondQuoteResponse = null; let lastStockQuoteResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " -- " + detail : ""}`); } async function expectText(page, selector, expected, name, timeout = 10000) { try { await page.waitForSelector(selector, { timeout }); await page.waitForFunction( ({ sel, want }) => { const el = document.querySelector(sel); return el !== null && (el.textContent || "").trim() === want; }, { sel: selector, want: expected }, { timeout } ); check(name, true); } catch (e) { let got = ""; try { got = ((await page.textContent(selector)) || "").trim(); } catch {} check(name, false, `got="${got}" want="${expected}"`); } } async function summaryValue(page, label, expected, name) { try { await page.waitForFunction( ({ want }) => { const rows = Array.from(document.querySelectorAll(".fund-detail-row")); return rows.some((r) => { const l = r.querySelector(".fund-detail-label"); const v = r.querySelector(".fund-detail-value"); return l && v && (l.textContent || "").trim() === want.label && (v.textContent || "").trim() === want.value; }); }, { want: { label, value: expected } }, { timeout: 10000 } ); check(name, true); } catch (e) { check(name, false, `summary row ${label} != "${expected}"`); } } async function summaryLabelExists(page, label, name) { try { await page.waitForFunction( (want) => { const rows = Array.from(document.querySelectorAll(".fund-detail-row")); return rows.some((r) => (r.querySelector(".fund-detail-label")?.textContent || "").trim() === want); }, label, { timeout: 10000 } ); check(name, true); } catch (e) { check(name, false, `summary row ${label} missing`); } } (async () => { const browser = await chromium.launch({ executablePath: EXE, headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); page.on("pageerror", (e) => consoleErrors.push("pageerror: " + e.message)); page.on("response", async (r) => { if (r.url().includes("/api/funds") && r.request().method() === "POST" && r.status() < 400) { fundPostResponses++; try { if (!createdFundResponse) createdFundResponse = await r.json(); } catch {} } if (/\/api\/funds\/[0-9a-f-]{36}$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) fundGets++; if (r.url().includes("/api/funds/") && r.url().includes("/orders") && r.request().method() === "GET" && r.status() < 400) { orderGets++; try { lastOrdersResponse = await r.json(); } catch {} } if (r.url().includes("/orders") && r.request().method() === "POST") orderPostStatuses.push(r.status()); if (r.url().includes("/capital") && r.request().method() === "POST") capitalPostStatuses.push(r.status()); if (r.url().includes("/confirm") && r.url().includes("/redemptions") && r.request().method() === "POST") { try { lastRedemptionConfirmResponse = await r.json(); redemptionConfirmResponses.push(r.status()); } catch {} } else if (r.url().includes("/confirm") && r.request().method() === "POST") { confirmPostStatuses.push(r.status()); try { lastConfirmResponse = await r.json(); } catch {} } if (r.url().includes("/positions") && r.request().method() === "GET" && r.status() < 400) { positionGets++; try { lastPositionsResponse = await r.json(); } catch {} } if (r.url().includes("/returns") && r.request().method() === "GET" && r.status() < 400) { try { lastReturnsResponse = await r.json(); } catch {} } if (r.url().includes("/investment-plans/run") && r.request().method() === "POST" && r.status() < 400) { try { lastPlanRunResponse = await r.json(); } catch {} } if (/\/investment-plans$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { try { lastPlansResponse = await r.json(); } catch {} } if (/\/rebalance\/plans$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { try { lastRebalancePlansResponse = await r.json(); } catch {} } if (/\/rebalance\/plans\/[0-9a-f-]+\/preview$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { try { lastRebalancePreviewResponse = await r.json(); } catch {} } if (/\/rebalance\/plans\/[0-9a-f-]+\/execute$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) { try { lastRebalanceExecuteResponse = await r.json(); } catch {} } if (/\/rebalance\/executions$/i.test(new URL(r.url()).pathname) && r.request().method() === "GET" && r.status() < 400) { try { lastRebalanceExecutionsResponse = await r.json(); } catch {} } if (r.url().includes("/market/nav-dates") && r.request().method() === "GET" && r.status() < 400) { try { lastNavDatesResponse = await r.json(); } catch {} } if (r.url().includes("/market/nav-series") && r.request().method() === "GET" && r.status() < 400) { try { lastNavSeriesResponse = await r.json(); } catch {} } if (r.url().includes("/market/bond-quote") && r.request().method() === "GET" && r.status() < 400) { try { lastBondQuoteResponse = await r.json(); } catch {} } if (r.url().includes("/market/stock-quote") && r.request().method() === "GET" && r.status() < 400) { try { lastStockQuoteResponse = await r.json(); } catch {} } if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); page.on("request", (r) => { if (r.url().includes("/api/funds") && r.method() === "POST" && !r.url().includes("/orders")) { fundPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } if (r.url().includes("/orders") && !r.url().includes("/confirm") && r.method() === "POST") { orderPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } if (r.url().includes("/confirm") && r.method() === "POST") { confirmPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "" }); } if (r.url().includes("/redemptions") && r.method() === "POST" && !r.url().includes("/confirm")) { redemptionPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } if (r.url().includes("/capital") && r.method() === "POST") { capitalPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } }); page.on("console", (m) => { if (/^DBG /.test(m.text())) console.log("[dbg] " + m.text()); if (m.type() !== "error") return; const text = m.text(); if (/favicon/i.test(text)) return; if (/401 \(Unauthorized\)/.test(text)) return; if (/orders/.test(text)) return; if (/Failed to load resource/.test(text) && !text.includes("api/")) return; consoleErrors.push("console: " + text); }); try { await page.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); await expectVisibleFundPanel(page); await validationScenario(page); await createScenario(page); await rereadScenario(page); await staleTokenScenario(page); await idempotentRetryScenario(page); await orderScenario(page); await confirmScenario(page); await redeemScenario(page); await capitalScenario(page); await dividendScenario(page); await returnsScenario(page); await navDatesScenario(page); await navSeriesScenario(page); await plansScenario(page); await rebalanceScenario(page); await bondsScenario(page); await stocksScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); } async function redeemScenario(page) { // K 系列: 赎回提交(冻结份额) -> 确认(现金到账+份额核销),接 J 系列持仓 84.01949252 await page.waitForSelector(".redeem-panel", { timeout: 10000 }); const redeemEyebrow = ((await page.textContent(".redeem-panel .eyebrow")) || "").trim(); check("K1 赎回面板 eyebrow 为 06 / REDEEM", redeemEyebrow === "06 / REDEEM", redeemEyebrow); await page.fill(".redemption-code-input", "000001"); await page.fill(".redemption-units-input", "34.01949252"); await page.fill(".redemption-fee-input", "1.00"); await page.click(".redemption-submit-action"); await page.waitForSelector(".pending-redemptions .order-row", { timeout: 15000 }); const submittedRedemption = ((await page.textContent(".pending-redemptions")) || ""); check( "K2 提交赎回后显示已提交·待确认", submittedRedemption.includes("已提交 · 待确认") && submittedRedemption.includes("份额 34.01949252"), submittedRedemption.slice(0, 200) ); // 提交后: 份额冻结但总份额与现金不变 await page.waitForFunction( () => { const panel = document.querySelector(".positions-panel"); return panel !== null && panel.textContent.includes("冻结份额 34.01949252"); }, null, { timeout: 15000 } ); const frozenCash = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); check( "K3 提交后冻结份额且现金不变", frozenCash.includes("4898.51") && frozenCash.includes("0.00"), JSON.stringify(frozenCash) ); await page.click(".redemption-confirm-action"); for (let i = 0; i < 200 && (redemptionConfirmResponses.length === 0 || lastRedemptionConfirmResponse === null); i++) { await page.waitForTimeout(100); } const redemptionStatus = lastRedemptionConfirmResponse ? lastRedemptionConfirmResponse.status : ""; check("K4 赎回确认响应为 confirmed", redemptionStatus === "confirmed", redemptionStatus); check( "K5 赎回确认展示 NAV 与到账金额精确字符串", lastRedemptionConfirmResponse.confirmedNav === "1.19020000" && /^[0-9]+\.[0-9]{2}$/.test(lastRedemptionConfirmResponse.confirmedProceeds || "") && /^[0-9]+\.[0-9]{2}$/.test(lastRedemptionConfirmResponse.confirmedCostReleased || ""), JSON.stringify(lastRedemptionConfirmResponse).slice(0, 220) ); await page.waitForFunction( () => { const panel = document.querySelector(".positions-panel"); return panel !== null && panel.textContent.includes("份额 50.00000000"); }, null, { timeout: 15000 } ); const proceeds = parseFloat(lastRedemptionConfirmResponse.confirmedProceeds); const costReleased = parseFloat(lastRedemptionConfirmResponse.confirmedCostReleased); const expectedAvailable = (4898.51 + proceeds).toFixed(2); const expectedCost = (99.99 - costReleased).toFixed(2); await page.waitForFunction( (want) => { const values = Array.from(document.querySelectorAll(".positions-panel .fund-detail-value")).map((el) => el.textContent.trim()); return values.includes(want.available); }, { available: expectedAvailable }, { timeout: 15000 } ); const settledCash = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); const positionText = ((await page.textContent(".positions-panel")) || ""); check( "K6 确认后份额核销且现金按应收入账", positionText.includes("份额 50.00000000") && !positionText.includes("冻结份额") && positionText.includes(expectedCost) && settledCash.includes(expectedAvailable) && settledCash.includes("0.00"), JSON.stringify({ positionText: positionText.slice(0, 220), settledCash, expectedAvailable, expectedCost }) ); const redemptionRowText = ((await page.textContent(".pending-redemptions")) || ""); check( "K7 赎回订单行显示已确认与到账金额", redemptionRowText.includes("已确认") && redemptionRowText.includes(`赎回到账 ${lastRedemptionConfirmResponse.confirmedProceeds}`), redemptionRowText.slice(0, 240) ); // 份额上限校验: 超额赎回 409 且不产生冻结 await page.fill(".redemption-units-input", "999999999.00000000"); await page.fill(".redemption-fee-input", "0.00"); await page.click(".redemption-submit-action"); await page.waitForSelector(".error-banner", { timeout: 15000 }); const insufficientText = ((await page.textContent(".error-banner")) || "").trim(); check( "K8 超额赎回被 409 拒绝", insufficientText.includes("available holdings are not enough"), insufficientText.slice(0, 140) ); const frozenAfterConflict = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); check( "K9 超额赎回后现金与冻结不变", frozenAfterConflict.includes(expectedAvailable) && !((await page.textContent(".positions-panel")) || "").includes("冻结份额"), JSON.stringify(frozenAfterConflict) ); await page.screenshot({ path: SHOTS + "/13-redemption-settled.png" }); } async function capitalScenario(page) { // J12-J17: 追加资金(接 K 场景:现金 4937.99,持仓 50.00000000/成本 59.51 不变) const initialCash = "4937.99"; await page.waitForSelector(".capital-deposit-action", { timeout: 10000 }); await page.fill(".capital-amount-input", "0.00"); await page.click(".capital-deposit-action"); await expectText(page, ".error-banner", "追加金额必须是大于零的两位小数金额,例如 1000.00", "J12 非法金额(0.00)拒绝"); await page.fill(".capital-amount-input", "abc"); await page.click(".capital-deposit-action"); await expectText(page, ".error-banner", "追加金额必须是大于零的两位小数金额,例如 1000.00", "J13 非法金额(abc)拒绝"); check("J14 校验阶段无追加 POST", capitalPosts.length === 0, `posts=${capitalPosts.length}`); await page.fill(".capital-amount-input", "500.00"); await page.click(".capital-deposit-action"); for (let i = 0; i < 100 && capitalPostStatuses.length === 0; i++) { await page.waitForTimeout(100); } check("J15 追加 POST 恰好 1 次且幂等键 32 位十六进制", capitalPosts.length === 1 && /^[0-9a-f]{32}$/.test(capitalPosts[0].key), JSON.stringify(capitalPosts)); await page.waitForFunction( (want) => { const rows = Array.from(document.querySelectorAll(".fund-detail-row")); return rows.some((r) => (r.querySelector(".fund-detail-label")?.textContent || "").trim() === "可用现金" && (r.querySelector(".fund-detail-value")?.textContent || "").trim() === want); }, "5437.99", { timeout: 15000 } ); check("J16 追加后现金即时刷新 5437.99", true); // 现金守恒:持仓份额与成本不变,现金差等于追加额 const summary = ((await page.textContent(".fund-summary")) || ""); const holdings = ((await page.textContent(".positions-panel")) || ""); check( "J17 追加不计入已实现收益(份额与成本不变)", holdings.includes("份额 50.00000000") && holdings.includes("成本 59.51") && summary.includes("5437.99"), holdings.slice(0, 200) ); // 幂等 ×1:同金额重试复用键 → replay 200,现金只入账一次 await page.click(".capital-deposit-action"); for (let i = 0; i < 100 && capitalPostStatuses.length < 2; i++) { await page.waitForTimeout(100); } await page.waitForTimeout(500); const summaryAfterRetry = ((await page.textContent(".fund-summary")) || ""); check( "J18 同键重放只入账一次", capitalPostStatuses.length === 2 && capitalPosts[0].key === capitalPosts[1].key && summaryAfterRetry.includes("5437.99"), JSON.stringify({ statuses: capitalPostStatuses, keys: capitalPosts.map((p) => p.key), summary: summaryAfterRetry.slice(0, 160) }) ); await page.screenshot({ path: SHOTS + "/14-capital-deposit.png" }); } async function currentFundId(page) { const rows = await page.$$eval(".fund-detail-row", (els) => els.map((el) => ({ label: (el.querySelector(".fund-detail-label")?.textContent || "").trim(), value: (el.querySelector(".fund-detail-value")?.textContent || "").trim(), })) ); const row = rows.find((r) => r.label === "基金ID"); const m = row ? /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(row.value) : null; return m ? m[0] : ""; } async function postDividendViaApi(page, fundId, token) { return page.evaluate( async ({ fundId, token }) => { const randomKey = () => { if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID().replace(/-/g, ""); return Math.random().toString(16).slice(2).padEnd(32, "0").slice(0, 32); }; const response = await fetch(`/api/funds/${fundId}/dividends`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": randomKey(), "Content-Type": "application/json", }, body: JSON.stringify({ instrumentCode: "000001", navDate: "2026-09-21", dps: "0.10000000", mode: "cash" }), }); return response.status; }, { fundId, token } ); } async function dividendScenario(page) { // L 系列: 分红派发记录可见性(只读历史)。K 场景后当前基金持有 000001 50.00000000 份。 await page.waitForSelector(".dividend-panel", { timeout: 10000 }); const fundId = await currentFundId(page); const token = process.env.QA_TOKEN || "qa-token"; const status = await postDividendViaApi(page, fundId, token); await page.click(".dividend-refresh-action"); const rowAppeared = await page .waitForSelector(".dividend-history .dividend-row", { timeout: 15000 }) .then(() => true) .catch(() => false); const historyText = ((await page.textContent(".dividend-history")) || ""); check( "L1 现金分红入账并出现在只读历史(含来源 key)", status === 200 && rowAppeared && historyText.includes("000001") && historyText.includes("持仓日期 2026-09-21") && historyText.includes("类型 现金") && historyText.includes("每份分红 0.10000000") && historyText.includes("入账现金 5.00") && historyText.includes("dividend-scheme:"), `status=${status} ${historyText.slice(0, 240)}` ); // 幂等:同一方案(同基金/代码/日期/模式/每份额)重放只保留一条记录 await postDividendViaApi(page, fundId, token); await page.click(".dividend-refresh-action"); await page.waitForTimeout(800); const rowCount = await page.locator(".dividend-history .dividend-row").count(); check("L2 同方案重放不产生第二条分红", rowCount === 1, `rows=${rowCount}`); await page.screenshot({ path: SHOTS + "/15-dividend-history.png" }); } async function returnsScenario(page) { // R 系列: 收益与资产曲线(接 L 场景:现金 5442.99,持仓 50.00000000 @1.1902 = 59.51) await page.waitForSelector(".returns-panel", { timeout: 10000 }); const eyebrow = ((await page.textContent(".returns-panel .eyebrow")) || "").trim(); check("R1 收益面板 eyebrow 为 10 / RETURNS", eyebrow === "10 / RETURNS", eyebrow); lastReturnsResponse = null; await page.click(".returns-refresh-action"); for (let i = 0; i < 200 && lastReturnsResponse === null; i++) { await page.waitForTimeout(100); } const returns = lastReturnsResponse || {}; const points = Array.isArray(returns.points) ? returns.points : []; const last = points.length > 0 ? points[points.length - 1] : {}; check( "R2 收益接口返回现金流调整后的总资产与累计收益", returns.pending === false && last.date === "2026-09-21" && last.totalAssets === "5502.50" && last.cumulativeReturn === "2.50" && last.cash === "5442.99" && last.holdingsValue === "59.51" && typeof last.unitNav === "string" && last.unitNav.length > 0, JSON.stringify(returns).slice(0, 280) ); await page.waitForSelector(".returns-panel .metric-strip", { timeout: 15000 }); const metricText = ((await page.textContent(".returns-panel .metric-strip")) || ""); const noteText = ((await page.textContent(".returns-panel .returns-note")) || ""); const markers = await page.locator(".returns-chart circle").count(); check( "R3 收益卡片与 API 一致、图表有点且不补零", metricText.includes("总资产") && metricText.includes("5502.50") && metricText.includes("累计收益") && metricText.includes("2.50") && metricText.includes("数据更新时间") && markers >= 1 && noteText.includes("不补零"), `metrics=${metricText.slice(0, 160)} markers=${markers}` ); await page.screenshot({ path: SHOTS + "/16-returns-curve.png" }); } async function navDatesScenario(page) { // R1: 真实 AKShare 净值日期下拉(可用时显示真实日期,不可用时显示提示)。 await page.waitForSelector(".returns-panel .returns-date-picker", { timeout: 10000 }); const token = process.env.QA_TOKEN || "qa-token"; for (let i = 0; i < 200 && lastNavDatesResponse === null; i++) { await page.waitForTimeout(100); } const navDates = lastNavDatesResponse || {}; const dates = Array.isArray(navDates.dates) ? navDates.dates : []; const pickerState = await page.getAttribute(".returns-date-picker", "data-nav-dates-state"); const optionValues = await page .locator(".returns-date-input option") .evaluateAll((elements) => elements.map((element) => element.value)); const emptyExists = (await page.locator(".returns-date-empty").count()) === 1; const emptyText = ((await page.textContent(".returns-date-empty")) || "").trim(); check( "R1a 真实可用时下拉显示真实净值日期", navDates.code === "000001" && dates.includes("2026-09-21") && pickerState === "available" && optionValues.includes("2026-09-21") && emptyExists && emptyText === "暂无真实净值数据", `state=${pickerState} dates=${JSON.stringify(dates).slice(0, 160)} options=${JSON.stringify(optionValues).slice(0, 160)}` ); const failure = await page.evaluate(async (token) => { const response = await fetch("/api/market/nav-dates?code=999999", { headers: { Authorization: `Bearer ${token}` }, }); let body = null; try { body = await response.json(); } catch {} return { status: response.status, body }; }, token); check( "R1b 不可用时返回 503 且保留提示路径", failure.status === 503 && failure.body && failure.body.error === "MARKET_DATA_UNAVAILABLE" && emptyExists && emptyText === "暂无真实净值数据", `status=${failure.status} body=${JSON.stringify(failure.body).slice(0, 160)}` ); await page.screenshot({ path: SHOTS + "/19-nav-dates.png" }); } async function navSeriesScenario(page) { // R2: 真实 AKShare 多日净值序列曲线(多日点、不补零;不可用 503 + 空态)。 await page.waitForSelector(".returns-panel .returns-nav-series", { timeout: 10000 }); const token = process.env.QA_TOKEN || "qa-token"; for (let i = 0; i < 200 && lastNavSeriesResponse === null; i++) { await page.waitForTimeout(100); } const series = lastNavSeriesResponse || {}; const points = Array.isArray(series.points) ? series.points : []; const positive = points.length > 0 && points.every((point) => Number.parseFloat(point.nav) > 0); const markers = await page.locator(".returns-nav-series .nav-series-marker").count(); const declaredPoints = await page.getAttribute(".returns-nav-series", "data-nav-series-points"); const emptyExists = (await page.locator(".returns-nav-series-empty").count()) === 1; const emptyText = ((await page.textContent(".returns-nav-series-empty")) || "").trim(); check( "R2a 序列可用时曲线显示多日净值且不补零", series.code === "000001" && points.length >= 2 && positive && points[0].navDate === "2026-09-21" && Number.parseInt(declaredPoints, 10) >= 2 && markers >= 2 && emptyExists && emptyText === "暂无真实净值数据", `code=${series.code} points=${JSON.stringify(points).slice(0, 200)} declared=${declaredPoints} markers=${markers}` ); const failure = await page.evaluate(async (token) => { const response = await fetch("/api/market/nav-series?code=999999", { headers: { Authorization: `Bearer ${token}` }, }); let body = null; try { body = await response.json(); } catch {} return { status: response.status, body }; }, token); check( "R2b 序列不可用时返回 503 且保留空态", failure.status === 503 && failure.body && failure.body.error === "MARKET_DATA_UNAVAILABLE" && emptyExists && emptyText === "暂无真实净值数据", `status=${failure.status} body=${JSON.stringify(failure.body).slice(0, 160)}` ); await page.screenshot({ path: SHOTS + "/20-nav-series.png" }); } async function bondsScenario(page) { // B 系列: 债券行情探针面板走真实 API(QA 环境由桩采集器提供,非前端 mock)。 await page.waitForSelector(".bonds-panel", { timeout: 10000 }); lastBondQuoteResponse = null; await page.fill(".bond-code-input", "110075"); await page.click(".bond-quote-action"); await page.waitForSelector(".bond-quote-result", { timeout: 10000 }); const resultText = ((await page.textContent(".bond-quote-result")) || "").trim(); const quote = lastBondQuoteResponse || {}; check( "B1 真实可用券返回名称与价格字符串", quote.code === "110075" && typeof quote.name === "string" && quote.name.length > 0 && typeof quote.price === "string" && quote.price.length > 0 && resultText.includes(quote.name) && resultText.includes(quote.price), `quote=${JSON.stringify(quote).slice(0, 160)} result=${resultText.slice(0, 160)}` ); await page.fill(".bond-code-input", "019547"); await page.click(".bond-quote-action"); await page.waitForFunction( () => { const element = document.querySelector(".bond-quote-unavailable"); return element && !element.hidden && element.textContent.includes("不可用"); }, { timeout: 10000 } ); const unavailableText = ((await page.textContent(".bond-quote-unavailable")) || "").trim(); const resultGone = (await page.locator(".bond-quote-result").count()) === 0; const token = process.env.QA_TOKEN || "qa-token"; const failure = await page.evaluate(async (token) => { const response = await fetch("/api/market/bond-quote?code=019547", { headers: { Authorization: `Bearer ${token}` }, }); let body = null; try { body = await response.json(); } catch {} return { status: response.status, body }; }, token); check( "B2 无有效报价时显示不可用提示且不补零", unavailableText.includes("不可用") && resultGone && failure.status === 503 && failure.body && failure.body.error === "MARKET_DATA_UNAVAILABLE", `message=${unavailableText.slice(0, 200)} status=${failure.status}` ); await page.screenshot({ path: SHOTS + "/21-bonds.png" }); } async function stocksScenario(page) { // S 系列: 股票行情探针面板走真实 API(QA 环境由桩采集器提供,非前端 mock)。 await page.waitForSelector(".stocks-panel", { timeout: 10000 }); lastStockQuoteResponse = null; await page.fill(".stock-code-input", "600519"); await page.click(".stock-quote-action"); await page.waitForSelector(".stock-quote-result", { timeout: 10000 }); const resultText = ((await page.textContent(".stock-quote-result")) || "").trim(); const quote = lastStockQuoteResponse || {}; check( "S1 真实可用股票返回名称与价格字符串", quote.code === "600519" && typeof quote.name === "string" && quote.name.includes("贵州茅台") && typeof quote.price === "string" && quote.price.length > 0 && resultText.includes("贵州茅台") && resultText.includes(quote.price), `quote=${JSON.stringify(quote).slice(0, 160)} result=${resultText.slice(0, 160)}` ); await page.fill(".stock-code-input", "999999"); await page.click(".stock-quote-action"); await page.waitForFunction( () => { const element = document.querySelector(".stock-quote-unavailable"); return element && !element.hidden && element.textContent.includes("股票行情不可用"); }, { timeout: 10000 } ); const unavailableText = ((await page.textContent(".stock-quote-unavailable")) || "").trim(); const resultGone = (await page.locator(".stock-quote-result").count()) === 0; const token = process.env.QA_TOKEN || "qa-token"; const failure = await page.evaluate(async (token) => { const response = await fetch("/api/market/stock-quote?code=999999", { headers: { Authorization: `Bearer ${token}` }, }); let body = null; try { body = await response.json(); } catch {} return { status: response.status, body }; }, token); check( "S2 无有效报价时显示不可用提示且不显示 0 占位", unavailableText.includes("股票行情不可用") && resultGone && !unavailableText.includes("0.00") && failure.status === 503 && failure.body && failure.body.error === "MARKET_DATA_UNAVAILABLE", `message=${unavailableText.slice(0, 200)} status=${failure.status}` ); await page.screenshot({ path: SHOTS + "/22-stocks.png" }); } async function postInvestmentPlanViaApi(page, fundId, token) { return page.evaluate( async ({ fundId, token }) => { const randomKey = () => { if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID().replace(/-/g, ""); return Math.random().toString(16).slice(2).padEnd(32, "0").slice(0, 32); }; const response = await fetch(`/api/funds/${fundId}/investment-plans`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": randomKey(), "Content-Type": "application/json", }, body: JSON.stringify({ instrumentCode: "000001", amount: "100.00", frequency: "daily" }), }); return response.status; }, { fundId, token } ); } async function plansScenario(page) { // P 系列: 定投计划执行最小闭环(接 R 场景:现金 5442.99,持仓 50.00000000) await page.waitForSelector(".plans-panel", { timeout: 10000 }); const fundId = await currentFundId(page); const token = process.env.QA_TOKEN || "qa-token"; const createStatus = await postInvestmentPlanViaApi(page, fundId, token); await page.click(".plans-refresh-action"); const planRowAppeared = await page .waitForSelector(".plans-list .order-row", { timeout: 15000 }) .then(() => true) .catch(() => false); const planListText = ((await page.textContent(".plans-list")) || ""); lastPlanRunResponse = null; await page.click(".plans-run-action"); for (let i = 0; i < 200 && lastPlanRunResponse === null; i++) { await page.waitForTimeout(100); } const run = lastPlanRunResponse || {}; const runPlans = Array.isArray(run.plans) ? run.plans : []; const firstPlan = runPlans.length > 0 ? runPlans[0] : {}; const runs = Array.isArray(firstPlan.runs) ? firstPlan.runs : []; const firstRun = runs.length > 0 ? runs[0] : {}; for (let i = 0; i < 200 && (lastPositionsResponse === null || lastPositionsResponse.availableCash !== "5343.00"); i++) { await page.waitForTimeout(100); } for (let i = 0; i < 200 && lastPlansResponse === null; i++) { await page.waitForTimeout(100); } const plans = Array.isArray(lastPlansResponse) ? lastPlansResponse : []; const plan = plans.length > 0 ? plans[0] : {}; check( "P1 到期定投计划按当日净值扣款买入", createStatus === 201 && planRowAppeared && planListText.includes("000001") && planListText.includes("金额 100.00") && planListText.includes("频率 每日") && plan.amount === "100.00" && plan.frequency === "daily" && run.processingDate === "2026-09-21" && firstRun.runDate === "2026-09-21" && firstRun.status === "succeeded" && lastPositionsResponse && lastPositionsResponse.availableCash === "5343.00" && lastPositionsResponse.positions?.[0]?.units === "134.01949252", `status=${createStatus} run=${JSON.stringify(run).slice(0, 240)} cash=${lastPositionsResponse?.availableCash}` ); lastPlanRunResponse = null; await page.click(".plans-run-action"); for (let i = 0; i < 200 && lastPlanRunResponse === null; i++) { await page.waitForTimeout(100); } const replay = lastPlanRunResponse || {}; const replayPlans = Array.isArray(replay.plans) ? replay.plans : []; const replayRuns = replayPlans.length > 0 && Array.isArray(replayPlans[0].runs) ? replayPlans[0].runs : []; check( "P2 重复执行幂等且不产生第二笔扣款", replayRuns.length === 1 && replayRuns[0].status === "succeeded" && lastPositionsResponse && lastPositionsResponse.availableCash === "5343.00" && lastPositionsResponse.positions?.[0]?.units === "134.01949252", `runs=${JSON.stringify(replayRuns).slice(0, 200)} cash=${lastPositionsResponse?.availableCash}` ); await page.screenshot({ path: SHOTS + "/17-investment-plans.png" }); } async function rebalanceScenario(page) { // R 系列: 再平衡最小闭环(接 P 场景:现金 5343.00,持仓 134.01949252 @1.1902) await page.waitForSelector(".rebalance-panel", { timeout: 10000 }); const eyebrow = ((await page.textContent(".rebalance-panel .eyebrow")) || "").trim(); check("P3 再平衡面板 eyebrow 为 12 / REBALANCE", eyebrow === "12 / REBALANCE", eyebrow); await page.locator(".rebalance-target-code-input").first().fill("000001"); await page.locator(".rebalance-target-percent-input").first().fill("100.00"); await page.click(".rebalance-submit-action"); const planRowAppeared = await page .waitForSelector(".rebalance-plan-row", { timeout: 15000 }) .then(() => true) .catch(() => false); for (let i = 0; i < 200 && lastRebalancePlansResponse === null; i++) { await page.waitForTimeout(100); } const plans = Array.isArray(lastRebalancePlansResponse) ? lastRebalancePlansResponse : []; const plan = plans.length > 0 ? plans[0] : {}; const planText = ((await page.textContent(".rebalance-plans")) || ""); check( "P4 目标权重计划创建(000001 100%)", planRowAppeared && planText.includes("000001 100.00%") && Array.isArray(plan.targets) && plan.targets[0]?.targetPercent === "100.00", `plans=${JSON.stringify(plans).slice(0, 200)}` ); lastRebalancePreviewResponse = null; await page.click(".rebalance-preview-action"); for (let i = 0; i < 200 && lastRebalancePreviewResponse === null; i++) { await page.waitForTimeout(100); } const preview = lastRebalancePreviewResponse || {}; const previewRows = Array.isArray(preview.rows) ? preview.rows : []; const previewRow = previewRows.length > 0 ? previewRows[0] : {}; const previewText = ((await page.textContent(".rebalance-preview")) || ""); check( "P5 预览展示目标对比当前及差额买入", previewRow.instrumentCode === "000001" && previewRow.targetPercent === "100.00" && previewRow.currentValue === "159.51" && previewRow.currentPercent === "2.89" && previewRow.action === "buy" && previewRow.deltaAmount === "5343.00" && preview.availableCash === "5343.00" && preview.equity === "5502.51" && previewText.includes("目标 100.00%") && previewText.includes("当前 2.89%"), `preview=${JSON.stringify(preview).slice(0, 260)}` ); lastRebalanceExecuteResponse = null; await page.click(".rebalance-execute-action"); for (let i = 0; i < 200 && lastRebalanceExecuteResponse === null; i++) { await page.waitForTimeout(100); } const execution = lastRebalanceExecuteResponse || {}; const outcomes = Array.isArray(execution.outcomes) ? execution.outcomes : []; for (let i = 0; i < 200 && (lastPositionsResponse === null || lastPositionsResponse.availableCash !== "0.01"); i++) { await page.waitForTimeout(100); } for (let i = 0; i < 200 && lastRebalanceExecutionsResponse === null; i++) { await page.waitForTimeout(100); } const executions = Array.isArray(lastRebalanceExecutionsResponse) ? lastRebalanceExecutionsResponse : []; const boughtUnits = parseFloat(lastPositionsResponse?.positions?.[0]?.units || "0"); check( "P6 执行再平衡按当日净值补足差额", execution.runDate === "2026-09-21" && outcomes.length === 1 && outcomes[0].instrumentCode === "000001" && outcomes[0].action === "buy" && outcomes[0].amount === "5343.00" && outcomes[0].status === "succeeded" && lastPositionsResponse && lastPositionsResponse.availableCash === "0.01" && boughtUnits > 134.01949252 && executions.length === 1 && executions[0].action === "buy" && executions[0].status === "succeeded", `outcomes=${JSON.stringify(outcomes).slice(0, 200)} cash=${lastPositionsResponse?.availableCash} units=${lastPositionsResponse?.positions?.[0]?.units}` ); const unitsAfterFirst = lastPositionsResponse?.positions?.[0]?.units; lastRebalanceExecuteResponse = null; await page.click(".rebalance-execute-action"); for (let i = 0; i < 200 && lastRebalanceExecuteResponse === null; i++) { await page.waitForTimeout(100); } const replay = lastRebalanceExecuteResponse || {}; const replayOutcomes = Array.isArray(replay.outcomes) ? replay.outcomes : []; check( "P7 再次执行容差内无新交易", replayOutcomes.length === 0 && lastPositionsResponse && lastPositionsResponse.availableCash === "0.01" && lastPositionsResponse.positions?.[0]?.units === unitsAfterFirst, `outcomes=${JSON.stringify(replayOutcomes).slice(0, 160)} cash=${lastPositionsResponse?.availableCash}` ); await page.screenshot({ path: SHOTS + "/18-rebalance.png" }); } const failed = results.filter((r) => !r.ok); console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); process.exit(failed.length > 0 ? 1 : 0); })().catch((e) => { console.error("QA driver crashed:", e); process.exit(2); }); async function expectVisibleFundPanel(page) { await page.waitForSelector(".fund-panel", { timeout: 15000 }); await page.waitForSelector(".fund-name-input", { timeout: 10000 }); await page.waitForSelector(".fund-cash-input", { timeout: 10000 }); check("A1 初始渲染: 创建面板/名称/现金输入框", true); const eyebrow = ((await page.textContent(".fund-panel .eyebrow")) || "").trim(); check("A2 面板 eyebrow 为 03 / CREATE", eyebrow === "03 / CREATE", eyebrow); const btn = ((await page.textContent(".fund-create-action")) || "").trim(); check("A3 创建按钮初始为 创建基金", btn === "创建基金", btn); const summaryCount = await page.locator(".fund-summary").count(); check("A4 初始无基金档案", summaryCount === 0, `count=${summaryCount}`); await page.screenshot({ path: SHOTS + "/01-initial.png" }); } async function validationScenario(page) { await page.fill(".token-input", "qa-token"); await page.fill(".fund-name-input", ""); await page.fill(".fund-cash-input", ""); await page.click(".fund-create-action"); await expectText(page, ".error-banner", "请输入基金名称", "B1 空名称校验提示"); await page.fill(".fund-name-input", "稳健一号"); await page.fill(".fund-cash-input", "abc"); await page.click(".fund-create-action"); await expectText( page, ".error-banner", "初始现金必须是合法金额,例如 10000.00", "B2 非法金额校验提示" ); await page.fill(".fund-cash-input", "-5.00"); await page.click(".fund-create-action"); const negativeBlocked = await page .waitForSelector(".error-banner", { timeout: 5000 }) .then(() => true) .catch(() => false); check("B3 负数金额被拒绝", negativeBlocked, "banner presence"); check("B4 校验阶段无 POST /api/funds", fundPosts.length === 0, `posts=${fundPosts.length}`); await page.screenshot({ path: SHOTS + "/02-validation.png" }); } async function createScenario(page) { await page.fill(".fund-name-input", "稳健一号"); await page.fill(".fund-cash-input", "12345.67"); await page.evaluate(() => { const btn = document.querySelector(".fund-create-action"); btn.click(); btn.click(); }); await page.waitForSelector(".fund-summary", { timeout: 15000 }); check("C1 创建阶段恰好 1 次 POST(双击防护)", fundPosts.length === 1, `posts=${fundPosts.length}`); const post = fundPosts[0] || { key: "", body: "" }; check("C2 幂等键为 32 位十六进制", /^[0-9a-f]{32}$/.test(post.key), post.key); let body = {}; try { body = JSON.parse(post.body); } catch {} check( "C3 请求体金额为 decimal 字符串", body.initialCash === "12345.67" && body.initialUnitNav === "1.00000000", post.body.slice(0, 200) ); check("C4 请求体 isSynthetic=false", body.isSynthetic === false, String(body.isSynthetic)); check("C5 请求体名称为 稳健一号", body.name === "稳健一号", String(body.name)); await summaryLabelExists(page, "基金ID", "C6 档案含基金ID"); await summaryValue(page, "初始现金", "12345.67", "C7 初始现金 12345.67"); await summaryValue(page, "初始单位净值", "1.00000000", "C8 初始单位净值 1.00000000"); await summaryValue(page, "份额", "0(新基金尚未买入持仓)", "C9 份额诚实显示 0"); await summaryValue(page, "数据来源", "真实建档", "C10 数据来源为 真实建档"); createdFund = { id: body.id || "", name: "稳健一号" }; try { const idRow = await page.locator(".fund-detail-row", { hasText: "基金ID" }).first().textContent(); const m = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(idRow || ""); if (m) createdFund.id = m[0]; } catch {} check( "C11 已捕获完整档案响应载荷", !!(createdFundResponse && createdFundResponse.id && createdFundResponse.name && createdFundResponse.availableCash), JSON.stringify(createdFundResponse || {}).slice(0, 160) ); await page.screenshot({ path: SHOTS + "/03-created.png" }); } async function rereadScenario(page) { await page.click(".fund-read-action"); await expectText(page, ".fund-read-action", "重新读取", "D1 重新读取完成按钮恢复", 15000); check("D2 重新读取恰好 1 次 GET", fundGets === 1, `gets=${fundGets}`); await summaryValue(page, "可用现金", "12345.67", "D3 重读后可用现金 12345.67"); await page.screenshot({ path: SHOTS + "/04-reread.png" }); } async function staleTokenScenario(page) { check( "E0 延迟响应使用完整档案载荷", !!(createdFundResponse && createdFundResponse.id && createdFundResponse.name && createdFundResponse.availableCash), JSON.stringify(createdFundResponse || {}).slice(0, 160) ); await page.route("**/api/funds/*", async (route) => { await new Promise((r) => setTimeout(r, 1200)); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(createdFundResponse || createdFund || {}), }); }); await page.click(".fund-read-action"); await page.fill(".token-input", "qa-token-rotated"); await page.waitForSelector(".fund-summary", { state: "detached", timeout: 10000 }); check("E1 换 token 立即清空基金档案", true); await page.waitForTimeout(1800); const summaryAfter = await page.locator(".fund-summary").count(); check("E2 延迟响应落地后档案仍被清除(stale 忽略)", summaryAfter === 0, `count=${summaryAfter}`); await page.unroute("**/api/funds/*"); await page.fill(".token-input", "qa-token"); await page.screenshot({ path: SHOTS + "/05-stale-token.png" }); } async function idempotentRetryScenario(page) { let aborted = false; await page.route("**/api/funds", async (route) => { if (!aborted) { aborted = true; await route.abort("failed"); } else { await route.continue(); } }); await page.fill(".fund-name-input", "稳健二号"); await page.fill(".fund-cash-input", "20000.00"); await page.click(".fund-create-action"); await page.waitForSelector(".error-banner", { timeout: 15000 }); const failedKey = fundPosts[fundPosts.length - 1]?.key || ""; check("F1 首次 POST 失败显示错误横幅", true); await page.click(".fund-create-action"); await page.waitForSelector(".fund-summary", { timeout: 15000 }); const retryKey = fundPosts[fundPosts.length - 1]?.key || ""; check("F2 重试复用同一 Idempotency-Key", failedKey !== "" && failedKey === retryKey, `${failedKey} vs ${retryKey}`); check("F3 失败后恰好 2 次 POST", fundPosts.length === 3, `posts=${fundPosts.length}`); await summaryValue(page, "名称", "稳健二号", "F4 重试成功档案为 稳健二号"); await page.unroute("**/api/funds"); await page.screenshot({ path: SHOTS + "/06-idempotent-retry.png" }); } async function orderScenario(page) { await page.waitForSelector(".subscribe-panel", { timeout: 10000 }); const eyebrow = ((await page.textContent(".subscribe-panel .eyebrow")) || "").trim(); check("G1 认购面板 eyebrow 为 04 / SUBSCRIBE", eyebrow === "04 / SUBSCRIBE", eyebrow); for (const sel of [".order-code-input", ".order-amount-input", ".order-fee-input", ".order-submit-action"]) { const count = await page.locator(sel).count(); check(`G1 认购面板包含 ${sel}`, count === 1, `count=${count}`); } const emptyHint = ((await page.textContent(".pending-orders")) || "").trim(); check("G2 初始无待确认订单提示", emptyHint.includes("暂无待确认订单"), emptyHint.slice(0, 40)); await page.click(".order-submit-action"); await expectText(page, ".error-banner", "请输入基金代码", "G3 空代码校验提示"); await page.fill(".order-code-input", "000001"); await page.fill(".order-amount-input", "abc"); await page.click(".order-submit-action"); await expectText( page, ".error-banner", "认购金额必须是大于零的两位小数金额,例如 100.00", "G4 非法金额校验提示" ); await page.fill(".order-amount-input", "100.00"); await page.fill(".order-fee-input", "-0.01"); await page.click(".order-submit-action"); await expectText( page, ".error-banner", "认购手续费必须是不小于零的两位小数金额,例如 0.00", "G5 负手续费校验提示" ); check("G6 校验阶段无认购 POST", orderPosts.length === 0, `posts=${orderPosts.length}`); await page.fill(".search-input", "000001"); await page.click(".search-row .primary-action"); await page.waitForSelector(".result-list", { timeout: 15000 }); const resultText = ((await page.textContent(".result-list")) || ""); check("G7 搜索到目录代码 000001", resultText.includes("000001"), resultText.slice(0, 80)); await page.fill(".order-code-input", "000001"); await page.fill(".order-amount-input", "100.00"); await page.fill(".order-fee-input", "1.50"); await page.click(".order-submit-action"); await page.waitForSelector(".order-row", { timeout: 15000 }); check("G8 成功下单恰好 1 次认购 POST", orderPosts.length === 1, `posts=${orderPosts.length}`); const post = orderPosts[0] || { key: "", body: "" }; check("G9 认购幂等键为 32 位十六进制", /^[0-9a-f]{32}$/.test(post.key), post.key); let body = {}; try { body = JSON.parse(post.body); } catch {} check( "G10 请求体为两位小数字符串", body.fundCode === "000001" && body.amount === "100.00" && body.feeAmount === "1.50", post.body.slice(0, 160) ); check("G11 认购响应 201 OrderCreated", orderPostStatuses[0] === 201, String(orderPostStatuses[0])); const rowText = ((await page.textContent(".pending-orders")) || ""); check( "G12 订单行为已提交·待确认且无份额收益", rowText.includes("000001") && rowText.includes("金额 100.00") && rowText.includes("预留合计 101.50") && rowText.includes("已提交 · 待确认") && !rowText.includes("份额") && !rowText.includes("收益"), rowText.slice(0, 200) ); await summaryValue(page, "可用现金", "19898.50", "G13 下单后可用现金 19898.50"); await summaryValue(page, "保留现金", "101.50", "G14 下单后保留现金 101.50"); check("G15 建档后自动读取订单列表", orderGets >= 1, `gets=${orderGets}`); await page.screenshot({ path: SHOTS + "/07-order-created.png" }); await page.fill(".order-amount-input", "19900.00"); await page.fill(".order-fee-input", "1.00"); await page.click(".order-submit-action"); await page.waitForSelector(".error-banner", { timeout: 15000 }); const insufficientText = ((await page.textContent(".error-banner")) || "").trim(); check( "G16 超额认购被 409 拒绝", insufficientText.includes("available cash is not enough"), insufficientText.slice(0, 120) ); const rowsAfterConflict = await page.locator(".order-row").count(); check("G17 409 后无新订单", rowsAfterConflict === 1, `rows=${rowsAfterConflict}`); await summaryValue(page, "可用现金", "19898.50", "G18 409 后可用现金不变"); await page.screenshot({ path: SHOTS + "/08-order-insufficient.png" }); let aborted = false; await page.route("**/api/funds/*/orders", async (route) => { if (route.request().method() !== "POST") { await route.continue(); return; } if (!aborted) { aborted = true; await route.abort("failed"); } else { await route.continue(); } }); await page.fill(".order-amount-input", "10.00"); await page.fill(".order-fee-input", "0.50"); await page.click(".order-submit-action"); await page.waitForSelector(".error-banner", { timeout: 15000 }); check("G19 网络失败显示错误横幅", true); const failedKey = orderPosts[orderPosts.length - 1]?.key || ""; await page.click(".order-submit-action"); await page.waitForFunction(() => document.querySelectorAll(".order-row").length === 2, null, { timeout: 15000 }); const retryKey = orderPosts[orderPosts.length - 1]?.key || ""; check("G20 重试复用同一认购幂等键", failedKey !== "" && failedKey === retryKey, `${failedKey} vs ${retryKey}`); await summaryValue(page, "可用现金", "19888.00", "G21 重试后可用现金 19888.00"); await summaryValue(page, "保留现金", "112.00", "G22 重试后保留现金 112.00"); await page.unroute("**/api/funds/*/orders"); await page.screenshot({ path: SHOTS + "/09-order-retry.png" }); // ==== H 系列: 下单/读取请求身份隔离 + 零手续费 + 换基金隔离 ==== await page.route("**/api/funds/*/orders", async (route) => { if (route.request().method() !== "POST") { await route.continue(); return; } await new Promise((r) => setTimeout(r, 1200)); await route.continue(); }); await page.fill(".order-code-input", "000001"); await page.fill(".order-amount-input", "5.00"); await page.fill(".order-fee-input", "0.00"); await page.click(".order-submit-action"); await expectText(page, ".order-submit-action", "下单中...", "H1 延迟 POST 进入下单中状态"); const refreshStart = orderGets; await page.click(".orders-refresh-action"); for (let i = 0; i < 60 && orderGets === refreshStart; i++) { await page.waitForTimeout(100); } check("H2 并发刷新在 POST 在途时完成", orderGets > refreshStart, `gets=${orderGets}/${refreshStart}`); const midRows = await page.locator(".order-row").count(); check("H3 刷新落地时 POST 仍未完成", midRows === 2, `rows=${midRows}`); await page.waitForFunction(() => document.querySelectorAll(".order-row").length === 3, null, { timeout: 15000 }); const doneBtn = ((await page.textContent(".order-submit-action")) || "").trim(); check("H4 延迟 POST 完成后按钮恢复认购下单", doneBtn === "认购下单", doneBtn); await summaryValue(page, "可用现金", "19883.00", "H5 零手续费下单后可用现金 19883.00"); await summaryValue(page, "保留现金", "117.00", "H6 零手续费下单后保留现金 117.00"); const zeroFeeRow = ((await page.textContent(".pending-orders")) || ""); check( "H7 零手续费订单行金额 5.00 手续费 0.00 预留 5.00", zeroFeeRow.includes("金额 5.00") && zeroFeeRow.includes("手续费 0.00") && zeroFeeRow.includes("预留合计 5.00"), zeroFeeRow.slice(0, 200) ); await page.unroute("**/api/funds/*/orders"); await page.screenshot({ path: SHOTS + "/10-order-delayed-refresh.png" }); await page.route("**/api/funds/*/orders", async (route) => { if (route.request().method() !== "POST") { await route.continue(); return; } await new Promise((r) => setTimeout(r, 1200)); await route.continue(); }); await page.fill(".order-amount-input", "99999.00"); await page.fill(".order-fee-input", "0.01"); await page.click(".order-submit-action"); await expectText(page, ".order-submit-action", "下单中...", "H8 失败路径进入下单中状态"); const failRefreshStart = orderGets; await page.click(".orders-refresh-action"); for (let i = 0; i < 60 && orderGets === failRefreshStart; i++) { await page.waitForTimeout(100); } await page.waitForSelector(".error-banner", { timeout: 15000 }); const failText = ((await page.textContent(".error-banner")) || "").trim(); check("H9 延迟 409 显示不足横幅", failText.includes("available cash is not enough"), failText.slice(0, 120)); const failBtn = ((await page.textContent(".order-submit-action")) || "").trim(); check("H10 失败后按钮恢复(不被刷新卡死)", failBtn === "认购下单", failBtn); const failRows = await page.locator(".order-row").count(); check("H11 失败后无新订单行", failRows === 3, `rows=${failRows}`); await summaryValue(page, "可用现金", "19883.00", "H12 失败后可用现金不变"); await summaryValue(page, "保留现金", "117.00", "H13 失败后保留现金不变"); await page.unroute("**/api/funds/*/orders"); await page.route("**/api/funds/*/orders", async (route) => { if (route.request().method() !== "POST") { await route.continue(); return; } await new Promise((r) => setTimeout(r, 1500)); await route.continue(); }); await page.fill(".order-amount-input", "7.00"); await page.fill(".order-fee-input", "0.00"); await page.click(".order-submit-action"); await expectText(page, ".order-submit-action", "下单中...", "H14 换基金前 POST 在途"); await page.fill(".fund-name-input", "切换基金测试"); await page.fill(".fund-cash-input", "3000.00"); await page.click(".fund-create-action"); await page.waitForSelector(".order-row", { state: "detached", timeout: 10000 }); check("H15 换基金立即清空旧基金订单行", true); const switchBtn = ((await page.textContent(".order-submit-action")) || "").trim(); check("H16 换基金后按钮恢复(在途请求被丢弃)", switchBtn === "认购下单", switchBtn); const switchHint = ((await page.textContent(".pending-orders")) || "").trim(); check("H17 换基金后显示暂无待确认订单", switchHint.includes("暂无待确认订单"), switchHint.slice(0, 40)); await page.waitForTimeout(2200); const staleRows = await page.locator(".order-row").count(); check("H18 延迟 POST 落地后旧订单仍未出现", staleRows === 0, `rows=${staleRows}`); await summaryValue(page, "可用现金", "3000.00", "H19 新基金可用现金 3000.00"); await summaryValue(page, "保留现金", "0.00", "H20 新基金保留现金 0.00"); await page.unroute("**/api/funds/*/orders"); await page.screenshot({ path: SHOTS + "/11-fund-switch-isolated.png" }); await page.route("**/api/funds/*/orders", async (route) => { if (route.request().method() !== "GET") { await route.continue(); return; } await new Promise((r) => setTimeout(r, 1200)); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(lastOrdersResponse || []), }); }); await page.click(".orders-refresh-action"); await page.fill(".token-input", "qa-token-rotated"); await page.waitForSelector(".order-row", { state: "detached", timeout: 10000 }); check("G23 换 token 立即清空待确认订单", true); await page.waitForTimeout(1800); const rowsAfterStale = await page.locator(".order-row").count(); check("G24 延迟订单响应落地后仍被清除(stale 忽略)", rowsAfterStale === 0, `rows=${rowsAfterStale}`); await page.unroute("**/api/funds/*/orders"); await page.fill(".token-input", "qa-token"); await page.screenshot({ path: SHOTS + "/10-order-stale-token.png" }); } async function confirmScenario(page) { // J 系列: 确认动作 + 确认状态展示 + 持仓联动(G23/G24 换 token 后 createdFund 已清空,先重建基金) await page.waitForSelector(".positions-panel", { timeout: 10000 }); const panelEyebrow = ((await page.textContent(".positions-panel .eyebrow")) || "").trim(); check("J1 持仓面板 eyebrow 为 05 / HOLDINGS", panelEyebrow === "05 / HOLDINGS", panelEyebrow); const noFundHint = ((await page.textContent(".positions-panel")) || "").trim(); check("J2 创建基金前持仓面板提示", noFundHint.includes("创建基金后"), noFundHint.slice(0, 60)); await page.fill(".fund-name-input", "确认持仓测试"); await page.fill(".fund-cash-input", "5000.00"); await page.click(".fund-create-action"); await page.waitForSelector(".fund-summary", { timeout: 15000 }); await page.fill(".order-code-input", "000001"); await page.fill(".order-amount-input", "100.00"); await page.fill(".order-fee-input", "1.50"); await page.click(".order-submit-action"); await page.waitForSelector(".order-row", { timeout: 15000 }); const submittedText = ((await page.textContent(".pending-orders")) || ""); check( "J3 下单后显示已提交·待确认与交易日", submittedText.includes("已提交 · 待确认") && submittedText.includes("交易日 2026-09-21"), submittedText.slice(0, 160) ); check("J4 下单后无持仓行", (await page.locator(".position-row").count()) === 0, ""); // 采集净值入库(getNav 只读库,refreshNav 才写库),确保确认时可用 await page.fill(".search-input", "000001"); await page.click(".search-row .primary-action"); await page.waitForSelector(".result-list", { timeout: 15000 }); await page.click(".result-list .result-row"); await page.click(".nav-panel .primary-action"); await page.waitForSelector(".nav-panel .metric-strip", { timeout: 20000 }); await page.waitForTimeout(500); await page.click(".order-confirm-action"); for (let i = 0; i < 200 && (confirmPostStatuses.length === 0 || lastConfirmResponse === null); i++) { await page.waitForTimeout(100); } check("J5 确认 POST 恰好 1 次且幂等键 32 位十六进制", confirmPosts.length === 1 && /^[0-9a-f]{32}$/.test(confirmPosts[0].key), JSON.stringify(confirmPosts)); const confirmStatus = lastConfirmResponse ? lastConfirmResponse.status : ""; const confirmed = confirmStatus === "confirmed"; check("J6 确认响应状态为 confirmed 或 pending_nav", confirmed || confirmStatus === "pending_nav", confirmStatus); if (confirmed) { check( "J7 已确认分支展示 NAV/份额/日期精确字符串", lastConfirmResponse.confirmedNav === "1.19020000" && /^[0-9]+\.[0-9]{8}$/.test(lastConfirmResponse.confirmedUnits || "") && lastConfirmResponse.confirmedNavDate === "2026-09-21", JSON.stringify(lastConfirmResponse).slice(0, 200) ); } else { check( "J7 待净值分支展示 pendingReason", typeof lastConfirmResponse.pendingReason === "string" && lastConfirmResponse.pendingReason.length > 0, JSON.stringify(lastConfirmResponse).slice(0, 200) ); } // 订单行展示确认状态与结果 await page.waitForFunction( () => { const rows = Array.from(document.querySelectorAll(".order-row")); return rows.some((r) => r.textContent.includes("已确认") || r.textContent.includes("等待净值")); }, null, { timeout: 15000 } ); const orderRowText = ((await page.textContent(".pending-orders")) || ""); if (confirmed) { check( "J8 订单行显示已确认与确认净值 1.19020000", orderRowText.includes("已确认") && orderRowText.includes("确认净值 1.19020000"), orderRowText.slice(0, 240) ); } else { check( "J8 订单行显示等待净值与原因", orderRowText.includes("等待净值") && orderRowText.includes("净值尚未公布") === false, orderRowText.slice(0, 240) ); } // 持仓联动:确认后持仓接口数据展示 if (confirmed) { await page.waitForSelector(".position-row", { timeout: 15000 }); } else { await page.waitForFunction( () => { const panel = document.querySelector(".positions-panel"); return panel !== null && (panel.textContent.includes("暂无持仓") || panel.textContent.includes("持仓尚未读取") === false); }, null, { timeout: 15000 } ); } const positionsText = ((await page.textContent(".positions-panel")) || ""); const cashTexts = await page.$$eval(".positions-panel .fund-detail-value", (els) => els.map((el) => el.textContent.trim())); if (confirmed) { const units = lastConfirmResponse.confirmedUnits; check( "J9 确认后持仓行显示份额/成本/估值净值", positionsText.includes("000001") && positionsText.includes(`份额 ${units}`) && positionsText.includes("成本") && positionsText.includes("1.19020000"), positionsText.slice(0, 260) ); check("J10 确认后保留现金归零", cashTexts.includes("0.00"), JSON.stringify(cashTexts)); } else { check( "J9 待净值分支无持仓行且现金未动", (await page.locator(".position-row").count()) === 0 && cashTexts.includes("101.50"), JSON.stringify({ cashTexts, rows: await page.locator(".position-row").count() }) ); } // 现金核算: 下单冻结 reservedTotal;确认后 invested 转持仓成本、residual 退回、费用消耗 const cashCheck = confirmed ? Math.abs( parseFloat(cashTexts[0]) - (5000 - 101.50 + parseFloat(lastConfirmResponse.confirmedResidualCash || "0")) ) < 0.005 && cashTexts[1] === "0.00" : Math.abs(parseFloat(cashTexts[0]) + parseFloat(cashTexts[1]) - 5000) < 0.005; check( "J11 确认后现金核算一致", cashCheck, JSON.stringify({ cashTexts, residual: lastConfirmResponse ? lastConfirmResponse.confirmedResidualCash : null }) ); await page.screenshot({ path: SHOTS + "/12-confirm-positions.png" }); }