summaryrefslogtreecommitdiff
path: root/qa/driver/browser-test.js
blob: 7a454413428aaa456400ac85b8dc83e5050d7825 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
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" });
}