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 = []; let fundPostResponses = 0; let fundGets = 0; let createdFund = null; let createdFundResponse = 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 (r.url().includes("/api/funds/") && r.request().method() === "GET" && r.status() < 400) fundGets++; if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); page.on("request", (r) => { if (r.url().includes("/api/funds") && r.method() === "POST") { fundPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); } }); page.on("console", (m) => { if (m.type() !== "error") return; const text = m.text(); if (/favicon/i.test(text)) return; if (/401 \(Unauthorized\)/.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); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); } 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" }); }