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
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
|
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 = [];
let fundPostResponses = 0;
let fundGets = 0;
let orderGets = 0;
let createdFund = null;
let createdFundResponse = null;
let lastOrdersResponse = 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.url().includes("/orders") && 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.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/orders/.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.method() === "POST") {
orderPosts.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 (/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);
} 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" });
}
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.00");
await page.click(".order-submit-action");
await expectText(
page,
".error-banner",
"认购手续费必须是大于零的两位小数金额,例如 1.50",
"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" });
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" });
}
|