@szc-ft/mcp-szcd-client 0.28.1 → 0.29.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.
Files changed (36) hide show
  1. package/agents/szcd-component-expert.qoder.md +1 -0
  2. package/opencode-extension/skills/local-browser-test/SKILL.md +189 -53
  3. package/opencode-extension/skills/local-browser-test/lib/ant-adapter.js +735 -0
  4. package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +2427 -0
  5. package/opencode-extension/skills/local-browser-test/lib/chrome-finder.js +125 -0
  6. package/opencode-extension/skills/local-browser-test/lib/expect.js +392 -0
  7. package/opencode-extension/skills/local-browser-test/lib/shared-deps.js +137 -0
  8. package/opencode-extension/skills/local-browser-test/lib/test-plan.js +48 -0
  9. package/opencode-extension/skills/local-browser-test/lib/visual-compare.js +192 -0
  10. package/opencode-extension/skills/local-browser-test/local-browser-executor.js +735 -0
  11. package/opencode-extension/skills/local-browser-test/package.json +1 -0
  12. package/opencode-extension/skills/szcd-upload-zip/SKILL.md +122 -0
  13. package/package.json +1 -1
  14. package/qwen-extension/qwen-extension.json +1 -1
  15. package/qwen-extension/skills/local-browser-test/SKILL.md +189 -53
  16. package/qwen-extension/skills/local-browser-test/lib/ant-adapter.js +735 -0
  17. package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +2427 -0
  18. package/qwen-extension/skills/local-browser-test/lib/chrome-finder.js +125 -0
  19. package/qwen-extension/skills/local-browser-test/lib/expect.js +392 -0
  20. package/qwen-extension/skills/local-browser-test/lib/shared-deps.js +137 -0
  21. package/qwen-extension/skills/local-browser-test/lib/test-plan.js +48 -0
  22. package/qwen-extension/skills/local-browser-test/lib/visual-compare.js +192 -0
  23. package/qwen-extension/skills/local-browser-test/local-browser-executor.js +735 -0
  24. package/qwen-extension/skills/local-browser-test/package.json +1 -0
  25. package/qwen-extension/skills/szcd-upload-zip/SKILL.md +122 -0
  26. package/standard-skill/local-browser-test/SKILL.md +189 -53
  27. package/standard-skill/local-browser-test/lib/ant-adapter.js +735 -0
  28. package/standard-skill/local-browser-test/lib/browser-engine.js +2427 -0
  29. package/standard-skill/local-browser-test/lib/chrome-finder.js +125 -0
  30. package/standard-skill/local-browser-test/lib/expect.js +392 -0
  31. package/standard-skill/local-browser-test/lib/shared-deps.js +137 -0
  32. package/standard-skill/local-browser-test/lib/test-plan.js +48 -0
  33. package/standard-skill/local-browser-test/lib/visual-compare.js +192 -0
  34. package/standard-skill/local-browser-test/local-browser-executor.js +735 -0
  35. package/standard-skill/local-browser-test/package.json +1 -0
  36. package/standard-skill/szcd-upload-zip/SKILL.md +122 -0
@@ -0,0 +1,735 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 本地浏览器测试执行器 - CLI 入口
4
+ *
5
+ * 用法:
6
+ * node local-browser-executor.js --action observe --output /tmp/observe.json
7
+ * node local-browser-executor.js --action act --index 1 --observe-after
8
+ * node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
9
+ * node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
10
+ * node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0
11
+ * node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
12
+ * node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
13
+ *
14
+ * 参数:
15
+ * --action 直接执行动作,当前支持 observe/act/assert/api-capture
16
+ * --plan 测试计划 JSON 字符串
17
+ * --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
18
+ * --output 结果输出文件路径(默认 stdout)
19
+ * --cdp-url Chrome DevTools Protocol URL(默认自动检测 localhost:9222)
20
+ * --page-url 连接已有 Chrome 时的目标页面 URL
21
+ * --base-url 基础 URL,替换计划中的 {{baseUrl}} 占位符
22
+ *
23
+ * 退出码:
24
+ * 0 - 全部步骤执行成功
25
+ * 1 - 部分步骤失败
26
+ * 2 - 环境错误(Chrome 未安装 / 依赖缺失 / 无法连接)
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { BrowserEngine } from "./lib/browser-engine.js";
33
+
34
+ const __filename = fileURLToPath(import.meta.url);
35
+ const __dirname = path.dirname(__filename);
36
+
37
+ // ==================== 参数解析 ====================
38
+
39
+ function parseArgs() {
40
+ const args = process.argv.slice(2);
41
+ const parsed = {};
42
+
43
+ for (let i = 0; i < args.length; i++) {
44
+ switch (args[i]) {
45
+ case "--action":
46
+ parsed.action = args[++i];
47
+ break;
48
+ case "--plan":
49
+ parsed.planJson = args[++i];
50
+ break;
51
+ case "--plan-file":
52
+ parsed.planFile = args[++i];
53
+ break;
54
+ case "--output":
55
+ parsed.output = args[++i];
56
+ break;
57
+ case "--cdp-url":
58
+ parsed.cdpUrl = args[++i];
59
+ break;
60
+ case "--page-url":
61
+ parsed.pageUrl = args[++i];
62
+ break;
63
+ case "--url-contains":
64
+ parsed.urlContains = args[++i];
65
+ break;
66
+ case "--frame-url-contains":
67
+ parsed.frameUrlContains = args[++i];
68
+ break;
69
+ case "--frame-content-contains":
70
+ parsed.frameContentContains = args[++i];
71
+ break;
72
+ case "--frame-index":
73
+ parsed.frameIndex = args[++i];
74
+ break;
75
+ case "--selector":
76
+ parsed.selector = args[++i];
77
+ break;
78
+ case "--depth":
79
+ parsed.depth = args[++i];
80
+ break;
81
+ case "--max-interactive-elements":
82
+ parsed.maxInteractiveElements = args[++i];
83
+ break;
84
+ case "--index":
85
+ parsed.index = args[++i];
86
+ break;
87
+ case "--click":
88
+ parsed.click = args[++i];
89
+ break;
90
+ case "--type":
91
+ parsed.typeText = args[++i];
92
+ break;
93
+ case "--clear":
94
+ parsed.clear = true;
95
+ break;
96
+ case "--hover":
97
+ parsed.hover = args[++i];
98
+ break;
99
+ case "--menu-path":
100
+ parsed.menuPath = args[++i];
101
+ break;
102
+ case "--wait-after-each":
103
+ parsed.waitAfterEach = args[++i];
104
+ break;
105
+ case "--observe-after":
106
+ parsed.observeAfter = true;
107
+ break;
108
+ case "--url-contains-assert":
109
+ parsed.urlContainsAssert = args[++i];
110
+ break;
111
+ case "--text-contains":
112
+ parsed.textContains = args[++i];
113
+ break;
114
+ case "--expect-visible":
115
+ parsed.expectVisible = true;
116
+ break;
117
+ case "--min-count":
118
+ parsed.minCount = args[++i];
119
+ break;
120
+ case "--has-text":
121
+ parsed.hasText = args[++i];
122
+ break;
123
+ case "--wait":
124
+ parsed.wait = args[++i];
125
+ break;
126
+ case "--method":
127
+ parsed.method = args[++i];
128
+ break;
129
+ case "--url-pattern":
130
+ parsed.urlPattern = args[++i];
131
+ break;
132
+ case "--target-url-contains":
133
+ parsed.targetUrlContains = args[++i];
134
+ break;
135
+ case "--include-request-body":
136
+ parsed.includeRequestBody = true;
137
+ break;
138
+ case "--no-request-body":
139
+ parsed.includeRequestBody = false;
140
+ break;
141
+ case "--include-response-body":
142
+ parsed.includeResponseBody = true;
143
+ break;
144
+ case "--reload":
145
+ parsed.reload = true;
146
+ break;
147
+ case "--include-all-targets":
148
+ parsed.includeAllTargets = true;
149
+ break;
150
+ case "--capture-file":
151
+ parsed.captureFile = args[++i];
152
+ break;
153
+ case "--max-failed":
154
+ parsed.maxFailed = args[++i];
155
+ break;
156
+ case "--forbid-status-gte":
157
+ parsed.forbidStatusGte = args[++i];
158
+ break;
159
+ case "--required-url":
160
+ parsed.requiredUrls = [...(parsed.requiredUrls || []), args[++i]];
161
+ break;
162
+ case "--required-method":
163
+ parsed.requiredMethods = [...(parsed.requiredMethods || []), args[++i]];
164
+ break;
165
+ case "--min-total":
166
+ parsed.minTotal = args[++i];
167
+ break;
168
+ case "--base-url":
169
+ parsed.baseUrl = args[++i];
170
+ break;
171
+ case "--allow-launch":
172
+ parsed.allowLaunch = true;
173
+ break;
174
+ case "--task":
175
+ parsed.task = args[++i];
176
+ break;
177
+ case "--menu-api-pattern":
178
+ parsed.menuApiPattern = args[++i];
179
+ break;
180
+ case "--menu-limit":
181
+ parsed.menuLimit = Number.parseInt(args[++i], 10);
182
+ break;
183
+ case "--menu-path-prefix":
184
+ parsed.menuPathPrefix = (parsed.menuPathPrefix || []).concat(args[++i]);
185
+ break;
186
+ case "--menu-filter":
187
+ parsed.menuFilter = (parsed.menuFilter || []).concat(args[++i]);
188
+ break;
189
+ case "--drawer-trigger-selector":
190
+ parsed.drawerTriggerSelector = args[++i];
191
+ break;
192
+ case "--drawer-open-selector":
193
+ parsed.drawerOpenSelector = args[++i];
194
+ break;
195
+ case "--menu-scroll-container":
196
+ parsed.menuScrollContainer = args[++i];
197
+ break;
198
+ case "--use-native-hover":
199
+ parsed.useNativeHover = args[++i] !== "false";
200
+ break;
201
+ case "--max-retry-per-step":
202
+ parsed.maxRetryPerStep = Number.parseInt(args[++i], 10);
203
+ break;
204
+ case "--snapshot-each":
205
+ parsed.snapshotEach = true;
206
+ break;
207
+ case "--screenshot-each":
208
+ parsed.screenshotEach = true;
209
+ break;
210
+ case "--continue-on-fail":
211
+ parsed.continueOnFail = args[++i] !== "false";
212
+ break;
213
+ case "--no-continue-on-fail":
214
+ parsed.continueOnFail = false;
215
+ break;
216
+ case "--capture-input-file":
217
+ parsed.captureInputFile = args[++i];
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;
231
+ case "--help":
232
+ case "-h":
233
+ printHelp();
234
+ process.exit(0);
235
+ }
236
+ }
237
+ return parsed;
238
+ }
239
+
240
+ function printHelp() {
241
+ console.log(`
242
+ 本地浏览器测试执行器
243
+
244
+ 用法:
245
+ node local-browser-executor.js --action observe [options]
246
+ node local-browser-executor.js --action act --index 1 --observe-after [options]
247
+ node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after [options]
248
+ node local-browser-executor.js --action assert --selector ".ant-table" --expect-visible [options]
249
+ node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
250
+ node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
251
+ node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
252
+ node local-browser-executor.js --plan '<JSON>' [options]
253
+ node local-browser-executor.js --plan-file <path> [options]
254
+
255
+ 选项:
256
+ --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
257
+ --plan <json> 测试计划 JSON 字符串
258
+ --plan-file <path> 测试计划 JSON 文件路径
259
+ --output <path> 结果输出文件路径(默认 stdout)
260
+ --cdp-url <url> Chrome DevTools Protocol URL(默认 http://localhost:9222)
261
+ --page-url <url> 目标页面 URL(connect 模式时匹配标签页)
262
+ --url-contains <text> 按 URL 片段匹配已打开标签页
263
+ --frame-url-contains <text> 按 URL 片段匹配 iframe
264
+ --frame-content-contains <text> 按文本内容匹配 iframe
265
+ --frame-index <n> 按 frame 下标选择 iframe
266
+ --selector <css> observe/assert 根节点或目标元素(observe 默认 body)
267
+ --depth <n> observe 的 DOM 树深度(默认 5)
268
+ --max-interactive-elements <n> observe 的交互元素上限(默认 200)
269
+ --index <n> act 时按 observe 的 interactiveElements[index] 操作
270
+ --click <selector> act 时点击 selector/text=/role=/~ 目标
271
+ --type <text> act 时输入文本
272
+ --clear act 输入前清空目标
273
+ --hover <selector> act 时悬停 selector/text=/role=/~ 目标
274
+ --menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
275
+ --wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
276
+ --observe-after act 后再次 observe,返回 before/act/after 闭环结果
277
+ --url-contains-assert <text> assert 当前 URL 包含文本
278
+ --text-contains <text> assert 页面文本包含内容
279
+ --expect-visible assert selector 可见
280
+ --min-count <n> assert selector 数量下限
281
+ --has-text <text> assert selector 文本包含内容
282
+ --wait <ms> api-capture 监听时长(默认 15000)
283
+ --method <GET|POST> api-capture 按请求方法过滤
284
+ --url-pattern <text> api-capture 按 URL 片段过滤
285
+ --target-url-contains <text> api-capture 按 target URL 过滤 iframe/page target
286
+ --include-request-body api-capture 包含请求体(默认包含)
287
+ --no-request-body api-capture 不包含请求体
288
+ --include-response-body api-capture 调用 Network.getResponseBody 获取响应体
289
+ --reload api-capture 开始监听后刷新页面触发请求
290
+ --include-all-targets api-capture 不限制到匹配 frame,监听所有 page/iframe targets
291
+ --capture-file <path> assert-api 读取 api-capture JSON 结果
292
+ --max-failed <n> assert-api 允许失败请求数上限
293
+ --forbid-status-gte <n> assert-api 禁止出现大于等于该状态码的响应
294
+ --required-url <text> assert-api 要求出现的 URL 片段,可重复传入
295
+ --required-method <GET|POST> assert-api 要求出现的请求方法,可重复传入
296
+ --min-total <n> assert-api 要求捕获请求总数下限
297
+ --allow-launch action 模式允许无 CDP 时启动 headless Chrome
298
+ --base-url <url> 基础 URL,替换计划中的 {{baseUrl}} 占位符
299
+ --task <name> run-task 任务名(当前仅支持 menu-api-scan)
300
+ --capture-input-file <path> run-task 读取已抓到的菜单接口 JSON 作为 lastApiCapture
301
+ --menu-api-pattern <regex> run-task 在 lastApiCapture 中筛选菜单接口的正则(默认 listUserViewMenus)
302
+ --menu-limit <n> run-task 仅探索前 N 条菜单
303
+ --menu-path-prefix <text> run-task 仅探索 path 以此开头的菜单(可重复)
304
+ --menu-filter <text> run-task 仅探索 name/path 包含此关键字的菜单(可重复)
305
+ --drawer-trigger-selector <css> run-task / act 抽屉式菜单的触发器选择器
306
+ --drawer-open-selector <css> run-task / act 抽屉打开态选择器(默认 .ant-drawer-open)
307
+ --menu-scroll-container <css> run-task / act 菜单容器需要滚动时的容器选择器
308
+ --use-native-hover <true|false> run-task / act 使用 puppeteer 原生 hover 触发子菜单(默认 true)
309
+ --max-retry-per-step <n> run-task / act 每级菜单查找重试次数(默认 2)
310
+ --snapshot-each run-task 每条菜单成功后做一次轻量 observe
311
+ --screenshot-each run-task 失败时截图(依赖 outputDir,默认关闭)
312
+ --no-continue-on-fail run-task 第一条失败即终止(默认遇错继续)
313
+ -h, --help 显示帮助
314
+ `);
315
+ }
316
+
317
+ function loadPlan(args) {
318
+ let raw;
319
+ if (args.planJson) {
320
+ raw = args.planJson;
321
+ } else if (args.planFile) {
322
+ raw = fs.readFileSync(args.planFile, "utf8");
323
+ } else {
324
+ throw new Error("必须提供 --plan 或 --plan-file 参数");
325
+ }
326
+
327
+ // 模板变量替换:{{baseUrl}} → --base-url 参数值
328
+ if (args.baseUrl) {
329
+ raw = raw.replace(/\{\{baseUrl\}\}/g, args.baseUrl);
330
+ }
331
+
332
+ return JSON.parse(raw);
333
+ }
334
+
335
+ // ==================== 执行引擎 ====================
336
+
337
+ async function executePlan(plan, options) {
338
+ const engine = new BrowserEngine();
339
+ const results = {
340
+ planId: plan.planId || "unknown",
341
+ description: plan.description || "",
342
+ mode: null,
343
+ steps: [],
344
+ summary: {
345
+ total: plan.steps.length,
346
+ passed: 0,
347
+ failed: 0,
348
+ fidelity: null,
349
+ },
350
+ startTime: new Date().toISOString(),
351
+ };
352
+
353
+ const outputDir = plan.options?.outputDir || path.join("/tmp", `browser-test-${results.planId}`);
354
+ if (!fs.existsSync(outputDir)) {
355
+ fs.mkdirSync(outputDir, { recursive: true });
356
+ }
357
+
358
+ try {
359
+ // 初始化浏览器
360
+ const initResult = await engine.init({
361
+ cdpUrl: options.cdpUrl,
362
+ pageUrl: options.pageUrl || plan.options?.pageUrl,
363
+ viewport: plan.options?.viewport,
364
+ });
365
+ results.mode = initResult.mode;
366
+ results.browserInfo = initResult;
367
+
368
+ // 如果计划中有 navigate 步骤且当前是 connect 模式,导航到目标页面
369
+ const navigateStep = plan.steps.find((s) => s.type === "navigate");
370
+
371
+ // 逐步执行
372
+ for (let i = 0; i < plan.steps.length; i++) {
373
+ const step = plan.steps[i];
374
+ const stepResult = await engine.executeStep(step, { outputDir });
375
+ stepResult.index = i + 1;
376
+ results.steps.push(stepResult);
377
+
378
+ if (stepResult.status === "PASS") {
379
+ results.summary.passed++;
380
+ } else {
381
+ results.summary.failed++;
382
+ }
383
+
384
+ // 提取 fidelity
385
+ if (step.type === "compare" && stepResult.status === "PASS" && stepResult.fidelity !== undefined) {
386
+ results.summary.fidelity = stepResult.fidelity;
387
+ }
388
+
389
+ // 输出进度
390
+ const status = stepResult.status === "PASS" ? "✓" : "✗";
391
+ process.stderr.write(
392
+ ` ${status} Step ${i + 1}/${plan.steps.length} ${step.type}` +
393
+ (step.selector ? ` (${step.selector})` : "") +
394
+ (stepResult.duration ? ` [${stepResult.duration}ms]` : "") +
395
+ "\n"
396
+ );
397
+ }
398
+ } catch (err) {
399
+ results.error = err.message;
400
+ results.errorCode = err.code || "UNKNOWN";
401
+
402
+ if (err.code === "CHROME_NOT_FOUND" || err.code === "DEPENDENCY_MISSING") {
403
+ results.installGuide = err.message;
404
+ }
405
+ } finally {
406
+ await engine.close();
407
+ }
408
+
409
+ results.endTime = new Date().toISOString();
410
+ results.duration = new Date(results.endTime) - new Date(results.startTime);
411
+ results.outputDir = outputDir;
412
+
413
+ return results;
414
+ }
415
+
416
+ // ==================== 主流程 ====================
417
+
418
+ function executeAssertApiAction(args) {
419
+ if (!args.captureFile) {
420
+ return {
421
+ error: "必须提供 --capture-file 参数",
422
+ errorCode: "CAPTURE_FILE_REQUIRED",
423
+ };
424
+ }
425
+
426
+ const captureResult = JSON.parse(fs.readFileSync(args.captureFile, "utf8"));
427
+ const engine = new BrowserEngine();
428
+ return engine.assertApi(captureResult, {
429
+ maxFailed: args.maxFailed,
430
+ forbidStatusGte: args.forbidStatusGte,
431
+ requiredUrls: args.requiredUrls,
432
+ requiredMethods: args.requiredMethods,
433
+ minTotal: args.minTotal,
434
+ });
435
+ }
436
+
437
+ async function executeAction(args) {
438
+ const engine = new BrowserEngine();
439
+ const startedAt = new Date().toISOString();
440
+ try {
441
+ const cdpUrl = args.cdpUrl || "http://localhost:9222";
442
+ const debuggerAvailable = await engine.isDebuggerAvailable(cdpUrl);
443
+ if (!debuggerAvailable && !args.allowLaunch) {
444
+ return {
445
+ error: `无法连接到 Chrome 调试端口: ${cdpUrl}`,
446
+ errorCode: "CDP_UNAVAILABLE",
447
+ guide: "请先以 --remote-debugging-port=9222 启动用户真实浏览器;如确需启动 headless 回退,追加 --allow-launch。",
448
+ startTime: startedAt,
449
+ endTime: new Date().toISOString(),
450
+ };
451
+ }
452
+
453
+ const initResult = await engine.init({
454
+ cdpUrl,
455
+ pageUrl: args.pageUrl || args.urlContains,
456
+ });
457
+
458
+ switch (args.action) {
459
+ case "observe": {
460
+ const result = await engine.observe({
461
+ selector: args.selector,
462
+ depth: args.depth,
463
+ frameUrlContains: args.frameUrlContains,
464
+ frameContentContains: args.frameContentContains,
465
+ frameIndex: args.frameIndex,
466
+ maxInteractiveElements: args.maxInteractiveElements,
467
+ });
468
+ return { ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
469
+ }
470
+ case "act": {
471
+ const actResult = await engine.act({
472
+ index: args.index,
473
+ selector: args.selector,
474
+ click: args.click,
475
+ hover: args.hover,
476
+ menuPath: args.menuPath,
477
+ waitAfterEach: args.waitAfterEach,
478
+ typeText: args.typeText,
479
+ clear: args.clear,
480
+ frameUrlContains: args.frameUrlContains,
481
+ frameContentContains: args.frameContentContains,
482
+ frameIndex: args.frameIndex,
483
+ });
484
+ if (!args.observeAfter) {
485
+ return { type: "act", ...actResult, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
486
+ }
487
+ const after = await engine.observe({
488
+ depth: args.depth,
489
+ frameUrlContains: args.frameUrlContains,
490
+ frameContentContains: args.frameContentContains,
491
+ frameIndex: args.frameIndex,
492
+ maxInteractiveElements: args.maxInteractiveElements,
493
+ });
494
+ return { type: "actObserve", act: actResult, after, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
495
+ }
496
+ case "assert": {
497
+ const expect = {};
498
+ if (args.expectVisible) expect.visible = true;
499
+ if (args.minCount !== undefined) expect.minCount = Number.parseInt(args.minCount, 10);
500
+ if (args.hasText !== undefined) expect.hasText = args.hasText;
501
+ const result = await engine.assert({
502
+ selector: args.selector,
503
+ expect,
504
+ urlContains: args.urlContainsAssert,
505
+ textContains: args.textContains,
506
+ frameUrlContains: args.frameUrlContains,
507
+ frameContentContains: args.frameContentContains,
508
+ frameIndex: args.frameIndex,
509
+ });
510
+ return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
511
+ }
512
+ case "api-capture":
513
+ case "apiCapture": {
514
+ const result = await engine.captureApi({
515
+ wait: args.wait,
516
+ method: args.method,
517
+ urlPattern: args.urlPattern,
518
+ targetUrlContains: args.targetUrlContains,
519
+ includeRequestBody: args.includeRequestBody,
520
+ includeResponseBody: args.includeResponseBody,
521
+ reload: args.reload,
522
+ includeAllTargets: args.includeAllTargets,
523
+ frameUrlContains: args.frameUrlContains,
524
+ frameContentContains: args.frameContentContains,
525
+ frameIndex: args.frameIndex,
526
+ });
527
+ return { ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
528
+ }
529
+ case "run-task":
530
+ case "runTask": {
531
+ // 把外部 captureFile 当作 lastApiCapture 注入 context
532
+ const context = {};
533
+ if (args.captureInputFile) {
534
+ const fs = await import("node:fs");
535
+ try {
536
+ context.lastApiCapture = JSON.parse(fs.readFileSync(args.captureInputFile, "utf8"));
537
+ } catch (err) {
538
+ return {
539
+ error: `读取 capture 输入文件失败: ${err.message}`,
540
+ errorCode: "CAPTURE_INPUT_INVALID",
541
+ startTime: startedAt,
542
+ endTime: new Date().toISOString(),
543
+ };
544
+ }
545
+ }
546
+ const result = await engine.runTask({
547
+ task: args.task || "menu-api-scan",
548
+ menuApiPattern: args.menuApiPattern,
549
+ menuLimit: args.menuLimit,
550
+ menuPathPrefix: args.menuPathPrefix,
551
+ menuFilter: args.menuFilter,
552
+ drawerTriggerSelector: args.drawerTriggerSelector,
553
+ drawerOpenSelector: args.drawerOpenSelector,
554
+ menuScrollContainer: args.menuScrollContainer,
555
+ useNativeHover: args.useNativeHover,
556
+ maxRetryPerStep: args.maxRetryPerStep,
557
+ snapshotEach: args.snapshotEach,
558
+ screenshotEach: args.screenshotEach,
559
+ continueOnFail: args.continueOnFail,
560
+ apiCaptureOptions: {
561
+ wait: args.wait,
562
+ targetUrlContains: args.targetUrlContains,
563
+ includeAllTargets: args.includeAllTargets,
564
+ includeResponseBody: args.includeResponseBody !== false,
565
+ urlPattern: args.urlPattern,
566
+ },
567
+ }, context);
568
+ return { type: "runTask", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
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
+ }
627
+ default:
628
+ return {
629
+ error: `未知 action: ${args.action}`,
630
+ errorCode: "UNKNOWN_ACTION",
631
+ supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
632
+ startTime: startedAt,
633
+ endTime: new Date().toISOString(),
634
+ };
635
+ }
636
+ } catch (err) {
637
+ return {
638
+ error: err.message,
639
+ errorCode: err.code || "ACTION_FAILED",
640
+ installGuide: err.code === "CHROME_NOT_FOUND" || err.code === "DEPENDENCY_MISSING" ? err.message : undefined,
641
+ startTime: startedAt,
642
+ endTime: new Date().toISOString(),
643
+ };
644
+ } finally {
645
+ await engine.close();
646
+ }
647
+ }
648
+
649
+ async function main() {
650
+ const args = parseArgs();
651
+
652
+ if (args.action === "assert-api" || args.action === "assertApi") {
653
+ try {
654
+ const results = executeAssertApiAction(args);
655
+ outputResult(results, args.output);
656
+ process.exit(results.error ? 2 : results.passed === false ? 1 : 0);
657
+ } catch (err) {
658
+ const errorResult = {
659
+ error: err.message,
660
+ errorCode: "ASSERT_API_FAILED",
661
+ };
662
+ outputResult(errorResult, args.output);
663
+ process.exit(2);
664
+ }
665
+ }
666
+
667
+ if (args.action) {
668
+ const results = await executeAction(args);
669
+ outputResult(results, args.output);
670
+ process.exit(results.error ? 2 : 0);
671
+ }
672
+
673
+ let plan;
674
+ try {
675
+ plan = loadPlan(args);
676
+ } catch (err) {
677
+ const errorResult = {
678
+ error: err.message,
679
+ errorCode: "INVALID_PLAN",
680
+ };
681
+ outputResult(errorResult, args.output);
682
+ process.exit(2);
683
+ }
684
+
685
+ process.stderr.write(`\n浏览器测试执行器\n`);
686
+ process.stderr.write(`计划: ${plan.description || plan.planId || "unnamed"}\n`);
687
+ process.stderr.write(`步骤: ${plan.steps?.length || 0}\n\n`);
688
+
689
+ const results = await executePlan(plan, {
690
+ cdpUrl: args.cdpUrl,
691
+ pageUrl: args.pageUrl,
692
+ });
693
+
694
+ // 输出结果
695
+ outputResult(results, args.output);
696
+
697
+ // 输出摘要
698
+ process.stderr.write(`\n执行完成:\n`);
699
+ process.stderr.write(` 模式: ${results.mode || "failed"}\n`);
700
+ process.stderr.write(` 通过: ${results.summary.passed}/${results.summary.total}\n`);
701
+ process.stderr.write(` 失败: ${results.summary.failed}/${results.summary.total}\n`);
702
+ if (results.summary.fidelity !== null) {
703
+ process.stderr.write(` 还原度: ${results.summary.fidelity}%\n`);
704
+ }
705
+ process.stderr.write(` 耗时: ${results.duration}ms\n\n`);
706
+
707
+ // 退出码
708
+ if (results.error) {
709
+ process.exit(2);
710
+ } else if (results.summary.failed > 0) {
711
+ process.exit(1);
712
+ } else {
713
+ process.exit(0);
714
+ }
715
+ }
716
+
717
+ function outputResult(results, outputPath) {
718
+ const json = JSON.stringify(results, null, 2);
719
+
720
+ if (outputPath) {
721
+ const dir = path.dirname(outputPath);
722
+ if (!fs.existsSync(dir)) {
723
+ fs.mkdirSync(dir, { recursive: true });
724
+ }
725
+ fs.writeFileSync(outputPath, json, "utf8");
726
+ process.stderr.write(`结果已写入: ${outputPath}\n`);
727
+ } else {
728
+ process.stdout.write(json + "\n");
729
+ }
730
+ }
731
+
732
+ main().catch((err) => {
733
+ process.stderr.write(`Fatal error: ${err.message}\n`);
734
+ process.exit(2);
735
+ });