@szc-ft/mcp-szcd-client 0.27.4 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/expect.js ADDED
@@ -0,0 +1,392 @@
1
+ /**
2
+ * 语义断言层 — Playwright expect.poll 风格
3
+ *
4
+ * 设计目标:把"15×200ms 硬循环 + ad-hoc checkElement"统一成 Promise 风格的可组合断言。
5
+ * 所有断言返回 { passed, reason, actual, expected, duration },可直接进测试报告。
6
+ *
7
+ * 用法:
8
+ * const exp = createExpect(engine);
9
+ * await exp.poll(() => frame.$$eval(".ant-select-item-option", els => els.length > 0));
10
+ * await exp.locator(frame, "text=提交").toBeVisible({ timeout: 3000 });
11
+ * await exp.validation(frame).toPass();
12
+ * await exp.url(page).toContain("/catalog/list");
13
+ * await exp.api(captureResult).toAllPass();
14
+ */
15
+
16
+ const DEFAULT_TIMEOUT = 5000;
17
+ const DEFAULT_INTERVAL = 200;
18
+
19
+ /**
20
+ * 轮询直到 predicate 返回 truthy,否则超时
21
+ * @param {Function} predicate - 异步函数,返回值参与判定
22
+ * @param {object} options - { timeout, interval, message }
23
+ * @returns {Promise<{ passed, value, attempts, duration }>}
24
+ */
25
+ export async function poll(predicate, options = {}) {
26
+ const timeout = Number.parseInt(options.timeout ?? DEFAULT_TIMEOUT, 10);
27
+ const interval = Number.parseInt(options.interval ?? DEFAULT_INTERVAL, 10);
28
+ const start = Date.now();
29
+ let attempts = 0;
30
+ let lastValue;
31
+ let lastError;
32
+
33
+ while (Date.now() - start < timeout) {
34
+ attempts += 1;
35
+ try {
36
+ lastValue = await predicate();
37
+ if (lastValue) {
38
+ return {
39
+ passed: true,
40
+ value: lastValue,
41
+ attempts,
42
+ duration: Date.now() - start,
43
+ };
44
+ }
45
+ } catch (err) {
46
+ lastError = err;
47
+ }
48
+ await new Promise((r) => setTimeout(r, interval));
49
+ }
50
+
51
+ return {
52
+ passed: false,
53
+ value: lastValue,
54
+ attempts,
55
+ duration: Date.now() - start,
56
+ reason: lastError
57
+ ? `predicate threw: ${lastError.message}`
58
+ : options.message || `poll timeout after ${timeout}ms (last value: ${JSON.stringify(lastValue)?.slice(0, 200)})`,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * 元素定位断言器:locator(frame, selector) 返回带 toXxx 的对象
64
+ * selector 支持 text= / role= / ~xxx / 普通 CSS
65
+ */
66
+ export function locator(frame, selector) {
67
+ /**
68
+ * 在 frame 内查找匹配 selector 的第一个元素,返回该元素的可观测状态。
69
+ * 一次 evaluate 取齐所有状态,避免多次 round-trip。
70
+ */
71
+ async function probe() {
72
+ return frame.evaluate((sel) => {
73
+ function resolveSelector(s) {
74
+ if (!s) return null;
75
+ if (s.startsWith("~")) {
76
+ const cls = s.slice(1).replace(/^\./, "");
77
+ return `[class*="${cls}"]`;
78
+ }
79
+ return s;
80
+ }
81
+ function isVisible(el) {
82
+ const rect = el.getBoundingClientRect();
83
+ const style = window.getComputedStyle(el);
84
+ return rect.width > 0 && rect.height > 0
85
+ && style.display !== "none"
86
+ && style.visibility !== "hidden"
87
+ && Number(style.opacity) !== 0;
88
+ }
89
+ function isEnabled(el) {
90
+ return !el.disabled && el.getAttribute("aria-disabled") !== "true";
91
+ }
92
+ function findText(text) {
93
+ const candidates = Array.from(document.querySelectorAll(
94
+ "a,button,input,select,textarea,summary,span,div,label,[role],[data-testid]"
95
+ ));
96
+ return candidates.find((el) => {
97
+ if (!isVisible(el)) return false;
98
+ const t = (el.innerText || el.textContent || "").trim();
99
+ return t.includes(text);
100
+ });
101
+ }
102
+ function findRole(roleSpec) {
103
+ const match = roleSpec.match(/^([^\[]+)(?:\[name="([^"]+)"\])?/);
104
+ const role = match?.[1]?.trim();
105
+ const name = match?.[2]?.trim();
106
+ const all = Array.from(document.querySelectorAll(`[role="${role}"]`));
107
+ return all.find((el) => {
108
+ if (!isVisible(el)) return false;
109
+ if (!name) return true;
110
+ const t = (el.innerText || el.textContent || "").trim();
111
+ return t.includes(name) || (el.getAttribute("aria-label") || "").includes(name);
112
+ });
113
+ }
114
+
115
+ let el = null;
116
+ if (sel.startsWith("text=")) {
117
+ el = findText(sel.slice(5).trim().replace(/^["']|["']$/g, ""));
118
+ } else if (sel.startsWith("role=")) {
119
+ el = findRole(sel.slice(5));
120
+ } else {
121
+ try { el = document.querySelector(resolveSelector(sel)); } catch { el = null; }
122
+ }
123
+
124
+ if (!el) return { found: false };
125
+
126
+ const rect = el.getBoundingClientRect();
127
+ return {
128
+ found: true,
129
+ tagName: el.tagName,
130
+ text: (el.innerText || el.textContent || "").trim().slice(0, 200),
131
+ value: "value" in el ? el.value : null,
132
+ visible: isVisible(el),
133
+ enabled: isEnabled(el),
134
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
135
+ attributes: {
136
+ id: el.id || null,
137
+ class: el.className?.toString?.() || null,
138
+ placeholder: el.getAttribute("placeholder"),
139
+ "aria-label": el.getAttribute("aria-label"),
140
+ "aria-checked": el.getAttribute("aria-checked"),
141
+ title: el.getAttribute("title"),
142
+ },
143
+ };
144
+ }, selector);
145
+ }
146
+
147
+ return {
148
+ async toBeVisible(opts = {}) {
149
+ const r = await poll(async () => {
150
+ const s = await probe();
151
+ return s.found && s.visible ? s : null;
152
+ }, { ...opts, message: `expect ${selector} to be visible` });
153
+ return formatAssertResult(r, { assertion: "toBeVisible", selector });
154
+ },
155
+
156
+ async toBeHidden(opts = {}) {
157
+ const r = await poll(async () => {
158
+ const s = await probe();
159
+ return !s.found || !s.visible ? s || { found: false } : null;
160
+ }, { ...opts, message: `expect ${selector} to be hidden` });
161
+ return formatAssertResult(r, { assertion: "toBeHidden", selector });
162
+ },
163
+
164
+ async toBeEnabled(opts = {}) {
165
+ const r = await poll(async () => {
166
+ const s = await probe();
167
+ return s.found && s.enabled ? s : null;
168
+ }, { ...opts, message: `expect ${selector} to be enabled` });
169
+ return formatAssertResult(r, { assertion: "toBeEnabled", selector });
170
+ },
171
+
172
+ async toHaveText(expected, opts = {}) {
173
+ const r = await poll(async () => {
174
+ const s = await probe();
175
+ if (!s.found) return null;
176
+ return s.text.includes(expected) ? s : null;
177
+ }, { ...opts, message: `expect ${selector} to have text "${expected}"` });
178
+ return formatAssertResult(r, { assertion: "toHaveText", selector, expected });
179
+ },
180
+
181
+ async toHaveValue(expected, opts = {}) {
182
+ const r = await poll(async () => {
183
+ const s = await probe();
184
+ if (!s.found || s.value == null) return null;
185
+ return s.value === expected || s.value.includes?.(expected) ? s : null;
186
+ }, { ...opts, message: `expect ${selector} to have value "${expected}"` });
187
+ return formatAssertResult(r, { assertion: "toHaveValue", selector, expected });
188
+ },
189
+
190
+ async toHaveCount(expected, opts = {}) {
191
+ const r = await poll(async () => {
192
+ const count = await frame.evaluate((s) => {
193
+ try { return document.querySelectorAll(s.startsWith("~") ? `[class*="${s.slice(1).replace(/^\./, "")}"]` : s).length; }
194
+ catch { return -1; }
195
+ }, selector);
196
+ return count === expected ? count : null;
197
+ }, { ...opts, message: `expect ${selector} to have count ${expected}` });
198
+ return formatAssertResult(r, { assertion: "toHaveCount", selector, expected });
199
+ },
200
+
201
+ async toHaveAttribute(name, value, opts = {}) {
202
+ const r = await poll(async () => {
203
+ const s = await probe();
204
+ if (!s.found) return null;
205
+ const actual = s.attributes[name];
206
+ if (value === undefined) return actual != null ? actual : null;
207
+ return actual === value || actual?.includes?.(value) ? actual : null;
208
+ }, { ...opts, message: `expect ${selector} attribute ${name} to be "${value}"` });
209
+ return formatAssertResult(r, { assertion: "toHaveAttribute", selector, name, expected: value });
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
215
+ * 表单校验断言:检查 frame 内是否还有 form-item-explain-error
216
+ */
217
+ export function validation(frame) {
218
+ return {
219
+ async toPass(opts = {}) {
220
+ const r = await poll(async () => {
221
+ const errors = await frame.evaluate(() => {
222
+ const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error, .ant-form-item-has-error .ant-form-item-explain"));
223
+ return items
224
+ .filter((el) => {
225
+ const rect = el.getBoundingClientRect();
226
+ return rect.width > 0 && rect.height > 0;
227
+ })
228
+ .map((el) => {
229
+ const formItem = el.closest(".ant-form-item");
230
+ const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
231
+ return {
232
+ field: label,
233
+ message: (el.innerText || el.textContent || "").trim(),
234
+ };
235
+ });
236
+ });
237
+ return errors.length === 0 ? { errors: [] } : null;
238
+ }, { ...opts, message: "expect form validation to pass" });
239
+
240
+ if (r.passed) {
241
+ return formatAssertResult(r, { assertion: "validation.toPass", expected: "no errors" });
242
+ }
243
+ // 失败时把当前错误列表带回去
244
+ const currentErrors = await frame.evaluate(() => {
245
+ const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error, .ant-form-item-has-error .ant-form-item-explain"));
246
+ return items.map((el) => {
247
+ const formItem = el.closest(".ant-form-item");
248
+ const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
249
+ return { field: label, message: (el.innerText || el.textContent || "").trim() };
250
+ });
251
+ }).catch(() => []);
252
+
253
+ return formatAssertResult(r, {
254
+ assertion: "validation.toPass",
255
+ expected: "no errors",
256
+ actual: currentErrors,
257
+ });
258
+ },
259
+
260
+ async toHaveError(fieldOrMessage, opts = {}) {
261
+ const r = await poll(async () => {
262
+ const errors = await frame.evaluate(() => {
263
+ const items = Array.from(document.querySelectorAll(".ant-form-item-explain-error"));
264
+ return items.map((el) => {
265
+ const formItem = el.closest(".ant-form-item");
266
+ const label = formItem?.querySelector(".ant-form-item-label label, .ant-form-item-label")?.innerText?.trim() || "";
267
+ return { field: label, message: (el.innerText || el.textContent || "").trim() };
268
+ });
269
+ });
270
+ const matched = errors.find((e) =>
271
+ e.field.includes(fieldOrMessage) || e.message.includes(fieldOrMessage)
272
+ );
273
+ return matched || null;
274
+ }, { ...opts, message: `expect validation error for "${fieldOrMessage}"` });
275
+ return formatAssertResult(r, { assertion: "validation.toHaveError", expected: fieldOrMessage });
276
+ },
277
+ };
278
+ }
279
+
280
+ /**
281
+ * URL 断言(针对 page,不需要 poll,因为 url 通常已稳定,但提供选项化轮询)
282
+ */
283
+ export function url(page) {
284
+ return {
285
+ async toContain(expected, opts = {}) {
286
+ const r = await poll(async () => {
287
+ const u = page.url();
288
+ return u.includes(expected) ? u : null;
289
+ }, { timeout: opts.timeout ?? 1000, interval: 100, message: `expect url to contain "${expected}"` });
290
+ return formatAssertResult(r, { assertion: "url.toContain", expected, actual: page.url() });
291
+ },
292
+
293
+ async toMatch(regex, opts = {}) {
294
+ const re = regex instanceof RegExp ? regex : new RegExp(regex);
295
+ const r = await poll(async () => {
296
+ const u = page.url();
297
+ return re.test(u) ? u : null;
298
+ }, { timeout: opts.timeout ?? 1000, interval: 100 });
299
+ return formatAssertResult(r, { assertion: "url.toMatch", expected: re.toString(), actual: page.url() });
300
+ },
301
+ };
302
+ }
303
+
304
+ /**
305
+ * API 捕获结果断言(基于 captureApi 的返回值)
306
+ * @param {object} captureResult - captureApi 返回对象
307
+ * @param {object} [options] - { scope: "current-frame"|"all", frameUrlContains }
308
+ */
309
+ export function api(captureResult, options = {}) {
310
+ const allRequests = captureResult?.requests || [];
311
+ const scope = options.scope || "all";
312
+
313
+ // 按 frame 范围过滤请求
314
+ let requests = allRequests;
315
+ if (scope === "current-frame" && options.frameUrlContains) {
316
+ requests = allRequests.filter((r) => r.frameUrl && r.frameUrl.includes(options.frameUrlContains));
317
+ }
318
+
319
+ return {
320
+ toAllPass() {
321
+ const failed = requests.filter((r) => (r.status && r.status >= 400) || r.errorText);
322
+ const passed = failed.length === 0;
323
+ return {
324
+ passed,
325
+ assertion: "api.toAllPass",
326
+ expected: "all requests status < 400",
327
+ actual: failed.length === 0 ? "all ok" : failed,
328
+ totalRequests: allRequests.length,
329
+ scopedRequests: requests.length,
330
+ reason: passed ? null : `${failed.length}/${requests.length} requests failed`,
331
+ };
332
+ },
333
+
334
+ toHaveNoFailed() {
335
+ return this.toAllPass();
336
+ },
337
+
338
+ toInclude(urlPattern) {
339
+ const re = typeof urlPattern === "string" ? new RegExp(urlPattern) : urlPattern;
340
+ const matched = requests.find((r) => re.test(r.url));
341
+ return {
342
+ passed: Boolean(matched),
343
+ assertion: "api.toInclude",
344
+ expected: urlPattern.toString(),
345
+ actual: matched || null,
346
+ totalRequests: allRequests.length,
347
+ scopedRequests: requests.length,
348
+ reason: matched ? null : `no request matched ${urlPattern}`,
349
+ };
350
+ },
351
+
352
+ toHaveCount(expected) {
353
+ return {
354
+ passed: requests.length === expected,
355
+ assertion: "api.toHaveCount",
356
+ expected,
357
+ actual: requests.length,
358
+ totalRequests: allRequests.length,
359
+ scopedRequests: requests.length,
360
+ reason: requests.length === expected ? null : `got ${requests.length} requests`,
361
+ };
362
+ },
363
+ };
364
+ }
365
+
366
+ function formatAssertResult(pollResult, meta) {
367
+ return {
368
+ passed: pollResult.passed,
369
+ assertion: meta.assertion,
370
+ selector: meta.selector,
371
+ expected: meta.expected,
372
+ actual: meta.actual ?? pollResult.value,
373
+ attempts: pollResult.attempts,
374
+ duration: pollResult.duration,
375
+ reason: pollResult.passed ? null : pollResult.reason,
376
+ };
377
+ }
378
+
379
+ /**
380
+ * 工厂:返回绑定 frame/page 的 expect 接口
381
+ */
382
+ export function createExpect(target) {
383
+ return {
384
+ poll,
385
+ locator: (selector) => locator(target.frame || target, selector),
386
+ validation: () => validation(target.frame || target),
387
+ url: () => url(target.page || target),
388
+ api,
389
+ };
390
+ }
391
+
392
+ export default { poll, locator, validation, url, api, createExpect };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * plan → testCase 兼容编译器
3
+ *
4
+ * 把现有线性 plan JSON 自动升级为 testCase 对象,无需用户迁移。
5
+ * 规则:
6
+ * - plan.steps[] → testCase.steps
7
+ * - plan.dataset 存在 → 参数化执行
8
+ * - 新 step type(antSelect/antInput/expect/formFill 等)原样保留
9
+ * - 旧 step type(click/type/checkElement 等)也原样保留
10
+ */
11
+
12
+ /**
13
+ * 编译 plan JSON 为 testCase 对象
14
+ * @param {object} plan - 现有 plan 格式
15
+ * @returns {object} testCase 对象
16
+ */
17
+ export function compilePlan(plan) {
18
+ return {
19
+ title: plan.description || plan.planId || `auto-${Date.now()}`,
20
+ labels: plan.labels || [],
21
+ setup: {
22
+ findPage: plan.findPage || undefined,
23
+ findFrame: plan.findFrame || undefined,
24
+ apiCapture: plan.apiCapture || undefined,
25
+ },
26
+ dataset: plan.dataset || [],
27
+ steps: plan.steps || [],
28
+ recovery: plan.recovery || {},
29
+ teardown: plan.teardown || undefined,
30
+ // 透传元数据,报告使用
31
+ _planId: plan.planId,
32
+ _options: plan.options,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * 编译 plan 文件内容为 testCase 对象数组
38
+ * @param {object|object[]} fileContent - plan JSON 或 plan 数组
39
+ * @returns {object[]} testCase 数组
40
+ */
41
+ export function compilePlanFile(fileContent) {
42
+ if (Array.isArray(fileContent)) {
43
+ return fileContent.map(compilePlan);
44
+ }
45
+ return [compilePlan(fileContent)];
46
+ }
47
+
48
+ export default { compilePlan, compilePlanFile };
@@ -216,6 +216,18 @@ function parseArgs() {
216
216
  case "--capture-input-file":
217
217
  parsed.captureInputFile = args[++i];
218
218
  break;
219
+ case "--test-case":
220
+ parsed.testCaseJson = args[++i];
221
+ break;
222
+ case "--test-case-file":
223
+ parsed.testCaseFile = args[++i];
224
+ break;
225
+ case "--dataset-index":
226
+ parsed.datasetIndex = Number.parseInt(args[++i], 10);
227
+ break;
228
+ case "--test-report":
229
+ parsed.testReport = args[++i];
230
+ break;
219
231
  case "--help":
220
232
  case "-h":
221
233
  printHelp();
@@ -555,11 +567,68 @@ async function executeAction(args) {
555
567
  }, context);
556
568
  return { type: "runTask", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
557
569
  }
570
+ case "test-case":
571
+ case "testCase": {
572
+ // 加载 testCase 定义
573
+ let testCase;
574
+ if (args.testCaseFile) {
575
+ try {
576
+ testCase = JSON.parse(fs.readFileSync(args.testCaseFile, "utf8"));
577
+ } catch (err) {
578
+ return {
579
+ error: `读取 test-case 文件失败: ${err.message}`,
580
+ errorCode: "TEST_CASE_INVALID",
581
+ startTime: startedAt,
582
+ endTime: new Date().toISOString(),
583
+ };
584
+ }
585
+ } else if (args.testCaseJson) {
586
+ try {
587
+ testCase = JSON.parse(args.testCaseJson);
588
+ } catch (err) {
589
+ return {
590
+ error: `解析 --test-case JSON 失败: ${err.message}`,
591
+ errorCode: "TEST_CASE_INVALID",
592
+ startTime: startedAt,
593
+ endTime: new Date().toISOString(),
594
+ };
595
+ }
596
+ } else {
597
+ return {
598
+ error: "必须提供 --test-case 或 --test-case-file",
599
+ errorCode: "TEST_CASE_MISSING",
600
+ startTime: startedAt,
601
+ endTime: new Date().toISOString(),
602
+ };
603
+ }
604
+
605
+ // 支持 --dataset-index 仅执行某一行
606
+ if (args.datasetIndex !== undefined && Array.isArray(testCase.dataset)) {
607
+ if (args.datasetIndex < 0 || args.datasetIndex >= testCase.dataset.length) {
608
+ return {
609
+ error: `--dataset-index ${args.datasetIndex} 越界(共 ${testCase.dataset.length} 行)`,
610
+ errorCode: "DATASET_INDEX_OUT_OF_RANGE",
611
+ };
612
+ }
613
+ testCase.dataset = [testCase.dataset[args.datasetIndex]];
614
+ }
615
+
616
+ const result = await engine.executeTestCase(testCase, {});
617
+
618
+ if (args.testReport) {
619
+ const dir = path.dirname(args.testReport);
620
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
621
+ fs.writeFileSync(args.testReport, JSON.stringify(result, null, 2), "utf8");
622
+ process.stderr.write(`测试报告已写入: ${args.testReport}\n`);
623
+ }
624
+
625
+ return { type: "testCase", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
626
+ }
558
627
  default:
559
628
  return {
560
629
  error: `未知 action: ${args.action}`,
561
630
  errorCode: "UNKNOWN_ACTION",
562
- supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task"],
631
+ supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
563
632
  startTime: startedAt,
564
633
  endTime: new Date().toISOString(),
565
634
  };
@@ -547,6 +547,119 @@ cd ~/.szcd-mcp/deps && npm install --registry=https://registry.npmmirror.com --@
547
547
  | `setTimeout(awaitPage.bringToFront(), 30000)` 之外不做兜底 | 调用前自查"目标页是否还活着"(`findPage` / `_findFrame`),失败立刻退而求其次操作主帧 | bringToFront 在 OS 抢焦点失败时会无限挂起,没超时 |
548
548
  | iframe attach 失败就报错退出 | `attachRetries` 默认 3 次、间隔 1.5s 重抓 `browser.targets()` | wujie 的 blob iframe target 在主页面 ready 后才注册,时序敏感 |
549
549
  | 大批量探索每条都 throw | `_actMenuPath` 失败时返回 `acted:false + candidates`,配合 `runTask --continue-on-fail` | 探索性扫描遇到一条点不开就全部中断,没法发现整体面 |
550
+ | 手写 puppeteer 脚本绕过 `act` 操作 Ant Select | 用 `antSelect` 步骤(内置 mousedown + 轮询 + closeAllDropdowns + VERIFY) | Ant Design Select 必须 mousedown 触发,浮层在 body 末尾,选项异步渲染,单步 `act.evaluate` 无法覆盖四步组合 |
551
+ | 每个 Select 后忘记 `closeAllDropdowns` | `act` 自动检测上一次 antSelect/antTreeSelect 并预清理 | 下拉浮层残留会污染下一次 Select 操作,选项串选 |
552
+ | 硬编码 `sleep(15*200)` 等选项渲染 | `antSelect` 内置 `pollEval` 15×200ms 轮询,`expect(poll)` 可自定义条件 | 选项渲染时间受网络/数据量影响,固定 sleep 时机不可靠 |
553
+ | 表单校验用 ad-hoc evaluate 检查 | 用 `expect validation.toPass` 或 `formFill` 的 `validateAfter` | 语义断言自带 poll + 结构化错误信息(field + message),可直接进报告 |
554
+
555
+ ## Ant Design 适配层(6/22 反馈落地)
556
+
557
+ 把"触发器定位 → 浮层等待 → 选项交互 → VERIFY → 全局清理"固化为单步骤 JSON。
558
+
559
+ | type | 功能 | 关键参数 |
560
+ |------|------|---------|
561
+ | `antSelect` | Ant Select 选项选择 | `field`, `option`, `mode` |
562
+ | `antTreeSelect` | 树形选择 | `field`, `path` |
563
+ | `antInput` | 输入框(支持 `placeholder` 定位) | `field`/`placeholder`, `value` |
564
+ | `antRadio` / `antCheckbox` / `antSwitch` | 选择类 | `field`, `option`/`on` |
565
+ | `antDatePicker` / `antCascader` / `antUpload` | 其他控件 | 见各方法文档 |
566
+ | `formFill` | 批量填充 + 可选 `validateAfter` | `fields[]` |
567
+ | `closeAllDropdowns` | 关闭所有下拉浮层 | - |
568
+
569
+ **关键约束**:
570
+ - Ant Select 必须 `mousedown` 触发(不是 click)
571
+ - 选项异步渲染,内置 15×200ms 轮询
572
+ - 每次操作前后自动 `closeAllDropdowns`,`act` 入口检测残留并预清理
573
+ - 操作后 VERIFY `.ant-select-selection-item[title]` 回写
574
+
575
+ ```json
576
+ { "type": "antSelect", "field": "数据集名称", "option": "测试集A" }
577
+ { "type": "antInput", "placeholder": "挂接资源", "value": "abc" }
578
+ { "type": "formFill", "fields": [
579
+ { "label": "名称", "type": "input", "value": "{{name}}" },
580
+ { "label": "类型", "type": "select", "value": "{{type}}" }
581
+ ], "validateAfter": true }
582
+ ```
583
+
584
+ ## 用例 DSL(Test Case)
585
+
586
+ 把多个用例统一为参数化 testCase JSON:
587
+
588
+ ```bash
589
+ node local-browser-executor.js --action test-case --test-case-file /tmp/case.json [--dataset-index 0] [--test-report /tmp/report.json]
590
+ ```
591
+
592
+ ```json
593
+ {
594
+ "title": "创建数据集 - P0",
595
+ "dataset": [{ "name": "A", "type": "图像" }, { "name": "B", "type": "文本" }],
596
+ "steps": [
597
+ { "type": "formFill", "fields": [
598
+ { "label": "名称", "type": "input", "value": "{{name}}" },
599
+ { "label": "类型", "type": "select", "value": "{{type}}" }
600
+ ], "validateAfter": true },
601
+ { "type": "expect", "assertion": "validation.toPass" },
602
+ { "type": "act", "click": "text=提交" },
603
+ { "type": "expect", "assertion": "url.toContain", "value": "/list" }
604
+ ],
605
+ "recovery": { "maxRetriesPerStep": 2 }
606
+ }
607
+ ```
608
+
609
+ ## 语义断言(expect)
610
+
611
+ | assertion | 功能 |
612
+ |-----------|------|
613
+ | `validation.toPass` | 表单校验无错误 |
614
+ | `validation.toHaveError` | 某字段有错误 |
615
+ | `locator.toBeVisible` / `toHaveText` / `toHaveValue` / `toHaveCount` | 元素状态 |
616
+ | `url.toContain` / `url.toMatch` | URL 断言 |
617
+ | `api.toAllPass` / `api.toInclude` | API 断言 |
618
+
619
+ 所有断言内置 `poll` 轮询(默认 timeout 5s, interval 200ms)。
620
+
621
+ ## 微前端(wujie / qiankun)场景适配
622
+
623
+ **核心问题与解决:**
624
+
625
+ | 微前端坑 | 自动修复 |
626
+ |---------|---------|
627
+ | 子应用业务在 frame[1],每步都要重传 frameContentContains | testCase `setup.findFrame` 设一次,所有步骤默认走 `_activeFrame` |
628
+ | 子应用路由切换销毁 iframe,导致后续 step 都 evaluate 失败 | `_getTargetFrame` 每次执行前探活,失活自动召回(标记 `frameRecovered: true`) |
629
+ | Ant Select 的 `getPopupContainer` 把浮层渲染到父文档 | `antSelect` 触发器/浮层双 frame 协作:trigger 在子 frame mousedown,浮层在父 frame 找选项 |
630
+ | 残留浮层污染下一次操作 | `closeAllDropdowns` 跨 frame 清理(主 + 所有可访问子 frame) |
631
+ | `api.toAllPass` 因主应用埋点 404 误判子应用 P0 测试失败 | `step.scope: "current-frame"` 仅断言 `_activeFrame` 同源的请求 |
632
+
633
+ **testCase 微前端模板:**
634
+
635
+ ```json
636
+ {
637
+ "title": "数据集创建 P0",
638
+ "setup": {
639
+ "findFrame": { "contentContains": "数据集目录" }
640
+ },
641
+ "dataset": [{ "name": "A" }, { "name": "B" }],
642
+ "steps": [
643
+ { "type": "apiCapture", "urlPattern": "/api/dataset", "wait": 5000, "includeAllTargets": true },
644
+ { "type": "antInput", "field": "名称", "value": "{{name}}" },
645
+ { "type": "antSelect", "field": "类型", "option": "图像" },
646
+ { "type": "act", "click": "text=提交" },
647
+ { "type": "expect", "assertion": "validation.toPass" },
648
+ { "type": "expect", "assertion": "api.toAllPass", "scope": "current-frame" }
649
+ ]
650
+ }
651
+ ```
652
+
653
+ **关键参数:**
654
+
655
+ - `setup.findFrame.contentContains` — 一次定位子应用 frame
656
+ - `step.frameContentContains` — 单步覆盖(少用,违反 DRY)
657
+ - `step.scope: "current-frame"` — API 断言仅看 `_activeFrame` 同源请求
658
+ - `apiCapture.includeAllTargets: true` — 跨 page/iframe target 监听网络
659
+
660
+ **调试技巧:**
661
+
662
+ 每个 ant 步骤结果含 `triggerFrameUrl` / `popupFrameUrl`,可看到触发器和浮层实际在哪个 frame。若 `_dropdownFrame: "main"` 出现,说明该控件用了 `getPopupContainer` 跨 frame 渲染浮层。
550
663
 
551
664
  ## 非目标声明
552
665
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@szc-ft/mcp-szcd-client",
3
- "version": "0.27.4",
3
+ "version": "0.28.0",
4
4
  "description": "MCP client for szcd component library - auto-configures AI coding tools with MCP server, skills, agents and commands",
5
5
  "keywords": [
6
6
  "mcp",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "szcd-component-helper",
3
- "version": "0.27.4",
3
+ "version": "0.28.0",
4
4
  "description": "szcd 组件库 MCP 助手 — 查询组件信息、匹配需求、生成代码",
5
5
  "mcpServers": {
6
6
  "szcd-component-helper": {