summaryrefslogtreecommitdiff
path: root/qa/driver
diff options
context:
space:
mode:
Diffstat (limited to 'qa/driver')
-rw-r--r--qa/driver/browser-test.js280
-rw-r--r--qa/driver/package-lock.json28
-rw-r--r--qa/driver/package.json9
3 files changed, 317 insertions, 0 deletions
diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js
new file mode 100644
index 0000000..7a45441
--- /dev/null
+++ b/qa/driver/browser-test.js
@@ -0,0 +1,280 @@
+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" });
+}
diff --git a/qa/driver/package-lock.json b/qa/driver/package-lock.json
new file mode 100644
index 0000000..9592f5d
--- /dev/null
+++ b/qa/driver/package-lock.json
@@ -0,0 +1,28 @@
+{
+ "name": "fund-lab-qa-driver",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fund-lab-qa-driver",
+ "version": "1.0.0",
+ "license": "UNLICENSED",
+ "dependencies": {
+ "playwright-core": "^1.63.0"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/qa/driver/package.json b/qa/driver/package.json
new file mode 100644
index 0000000..74ac02a
--- /dev/null
+++ b/qa/driver/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "fund-lab-qa-driver",
+ "version": "1.0.0",
+ "private": true,
+ "license": "UNLICENSED",
+ "dependencies": {
+ "playwright-core": "^1.63.0"
+ }
+}