@szc-ft/mcp-szcd-client 0.29.0 → 0.30.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.
@@ -376,7 +376,7 @@ node "$EXECUTOR" \
376
376
  --output /tmp/browser-observe.json
377
377
  ```
378
378
 
379
- `observe` 输出 `frames`、`tree`、`interactiveElements[index]`、`selectorCandidates`、`bbox`、`state`。Agent 后续应优先使用 `index` 调用 `act`,不要从 DOM 树重新猜 selector。直接 `--action observe` 默认只连接用户已启动的真实浏览器 CDP;如确需无 CDP 时启动 headless 回退,追加 `--allow-launch`。
379
+ `observe` 输出 `frames`、`tree`、`interactiveElements[index]`、`selectorCandidates`、`bbox`、`state`、`wujieContext`(含 `shadowBodyText` 全文 + `shadowBodyTextLength`)。Agent 后续应优先使用 `index` 调用 `act`,不要从 DOM 树重新猜 selector。直接 `--action observe` 默认只连接用户已启动的真实浏览器 CDP;如确需无 CDP 时启动 headless 回退,追加 `--allow-launch`。
380
380
 
381
381
  ### 观察后按 index 交互(主链路闭环)
382
382
  ```bash
@@ -440,6 +440,38 @@ node "$EXECUTOR" \
440
440
 
441
441
  计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
442
442
 
443
+ ### 浏览器端 JS 执行(构造 testCase 前提取表单元数据)
444
+ ```bash
445
+ node "$EXECUTOR" \
446
+ --action evaluate \
447
+ --url-contains "platform.aicityos.com" \
448
+ --frame-index 1 \
449
+ --expression '(() => Array.from(document.querySelectorAll(".ant-form-item")).map(item => ({
450
+ label: item.querySelector(".ant-form-item-label")?.textContent?.trim() || "",
451
+ required: !!item.querySelector(".ant-form-item-required"),
452
+ error: item.querySelector(".ant-form-item-explain-error")?.textContent?.trim() || ""
453
+ })).filter(x => x.label))()' \
454
+ --fail-on-falsy \
455
+ --output /tmp/form-fields.json
456
+ ```
457
+
458
+ `--action evaluate` 在目标 frame 中执行任意 JS 表达式(需用 IIFE 包裹),返回 `{ result, passed }`。这是构造 testCase JSON 前的核心步骤:**无源码依赖地提取页面表单元数据、校验错误、Select 选项列表等**。
459
+
460
+ 关键参数:
461
+ - `--expression <js>`:执行的 JS 表达式(必填)
462
+ - `--fail-on-falsy`:结果为 falsy 时标记 FAIL
463
+ - `--expect-result-contains <text>`:结果 JSON 包含指定文本
464
+
465
+ ### 执行测试计划 JSON(ES Module 版)
466
+ ```bash
467
+ node "$EXECUTOR" \
468
+ --action plan \
469
+ --plan-file /tmp/test-plan.json \
470
+ --output /tmp/browser-test-result.json
471
+ ```
472
+
473
+ `--action plan` 使用 ES Module 执行器运行测试计划 JSON,支持全部步骤类型(包括 evaluate、antSelect 等)。与 `--action test-case` 的区别:plan 不走 dataset 循环,适合单次执行。
474
+
443
475
  ### 快速诊断(过渡兼容)
444
476
  ```bash
445
477
  NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/local-browser-executor.cjs" \
@@ -526,7 +558,8 @@ NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/local-browser-executor.
526
558
  --scroll down --scroll-amount 500
527
559
  ```
528
560
  选择器语法:
529
- - `text=Build` → 按文本内容查找(类似 Playwright text selector)
561
+ - `text=Build` → 按文本内容模糊查找,多个匹配时选 innerText 最短的元素(叶子节点优先,避免命中父容器)
562
+ - `text==Build` → 精确文本匹配(Playwright 风格),只匹配 innerText 完全相等的元素
530
563
  - `role=button` → 按 ARIA role 查找
531
564
  - `~ant-menu-item` → CSS class 模糊匹配(`[class*="ant-menu-item"]`)
532
565
  - `.my-class` → 精确 CSS 选择器
@@ -81,27 +81,32 @@ export class BrowserEngine {
81
81
  const frames = this.page.frames();
82
82
  const frameCount = frames.length;
83
83
  const mainFrame = this.page.mainFrame();
84
+ // JSON 输出返回完整文本不截断;stderr 日志仅截取前 200 字符预览
85
+ const LOG_PREVIEW_LEN = 200;
84
86
 
85
87
  // 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
86
88
  const wujieInfo = await mainFrame.evaluate(() => {
87
89
  const app = document.querySelector('wujie-app');
88
90
  if (!app) return null;
89
91
  const sr = app.shadowRoot;
92
+ const body = sr?.querySelector('body');
93
+ const fullText = body?.innerText || '';
90
94
  return {
91
95
  tagName: app.tagName,
92
96
  className: app.className,
93
97
  hasShadowRoot: !!sr,
94
98
  shadowChildren: sr?.children?.length || 0,
95
- shadowBodyText: sr?.querySelector('body')?.innerText?.substring(0, 80) || ''
99
+ shadowBodyText: fullText,
100
+ shadowBodyTextLength: fullText.length,
96
101
  };
97
102
  }).catch(() => null);
98
103
 
99
104
  if (wujieInfo) {
100
105
  const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
101
106
  process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
102
- if (wujieInfo.shadowBodyText) {
103
- process.stderr.write(`[wujie] shadow body preview: "${wujieInfo.shadowBodyText}"\n`);
104
- }
107
+ // stderr 只输出预览(不刷屏),JSON 中返回全文
108
+ const preview = wujieInfo.shadowBodyText?.substring(0, LOG_PREVIEW_LEN) || '';
109
+ process.stderr.write(`[wujie] shadow body (${wujieInfo.shadowBodyTextLength} chars): "${preview}${wujieInfo.shadowBodyTextLength > LOG_PREVIEW_LEN ? '...' : ''}"\n`);
105
110
  // 列出所有 frame 供排障
106
111
  for (let i = 0; i < frames.length; i++) {
107
112
  process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
@@ -443,17 +448,36 @@ export class BrowserEngine {
443
448
 
444
449
  function findBySelector(selector) {
445
450
  if (!selector) return null;
451
+ // text== 精确匹配(Playwright 兼容):只匹配 innerText 完全相等的元素
452
+ if (selector.startsWith("text==")) {
453
+ const expected = selector.slice(6).trim();
454
+ const filter = (el) => isVisibleEnabled(el);
455
+ const interactive = interactiveElements().filter(filter);
456
+ let hit = interactive.find((el) => getText(el) === expected || getName(el, getText(el)) === expected);
457
+ if (hit) return hit;
458
+ const all = Array.from(document.querySelectorAll("*")).filter(filter);
459
+ return all.find((el) => getText(el) === expected) || null;
460
+ }
461
+ // text= 模糊匹配:优先选 innerText 最短的元素(离目标文字最近的叶子节点)
462
+ // 避免命中包含目标文字的大容器(如 drawer 整体 div)
446
463
  if (selector.startsWith("text=")) {
447
464
  const expected = selector.slice(5).trim();
448
- // 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
449
- // 需要包含隐藏元素时显式 includeHidden=true
450
465
  const includeHidden = actOptions.includeHidden === true;
451
466
  const filter = (el) => includeHidden || isVisibleEnabled(el);
452
467
  const interactive = interactiveElements().filter(filter);
453
- let hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
454
- if (hit) return hit;
468
+ let hits = interactive.filter((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
469
+ if (hits.length > 0) {
470
+ // 按 innerText 长度升序:最短的最可能是目标元素本身而非父容器
471
+ hits.sort((a, b) => getText(a).length - getText(b).length);
472
+ return hits[0];
473
+ }
455
474
  const all = Array.from(document.querySelectorAll("*")).filter(filter);
456
- return all.find((el) => getText(el).includes(expected)) || null;
475
+ hits = all.filter((el) => getText(el).includes(expected));
476
+ if (hits.length > 0) {
477
+ hits.sort((a, b) => getText(a).length - getText(b).length);
478
+ return hits[0];
479
+ }
480
+ return null;
457
481
  }
458
482
  if (selector.startsWith("role=")) {
459
483
  const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
@@ -1249,8 +1273,23 @@ export class BrowserEngine {
1249
1273
 
1250
1274
  const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
1251
1275
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
1252
- return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1253
- .filter(isCandidate)
1276
+
1277
+ // 1. 先收集 portal 弹窗/抽屉中的元素(排在前面,避免 text= 匹配到外层容器)
1278
+ const portalSelectors = '.ant-drawer:not(.ant-drawer-closed), .ant-drawer-open .ant-drawer-content-wrapper, .ant-modal-wrap:not([style*="display: none"]), .ant-dropdown:not(.ant-dropdown-hidden)';
1279
+ const portalEls = [];
1280
+ document.querySelectorAll(portalSelectors).forEach(portal => {
1281
+ portal.querySelectorAll('a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*="btn"],[class*="button"],[class*="ant-btn"]').forEach(el => {
1282
+ if (isCandidate(el)) portalEls.push(el);
1283
+ });
1284
+ });
1285
+
1286
+ // 2. 再收集 body 中的元素,但要排除已在 portal 中的(避免重复)
1287
+ const portalSet = new Set(portalEls);
1288
+ const bodyEls = Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1289
+ .filter(el => isCandidate(el) && !portalSet.has(el));
1290
+
1291
+ // portal 元素排前面(优先级更高),body 元素排后面
1292
+ return portalEls.concat(bodyEls)
1254
1293
  .slice(0, limit)
1255
1294
  .map((el, idx) => {
1256
1295
  const rect = el.getBoundingClientRect();
@@ -2266,7 +2305,15 @@ export class BrowserEngine {
2266
2305
  const results = [];
2267
2306
  const dataset = testCase.dataset || [{ __row: 0 }];
2268
2307
 
2269
- // setup 阶段:frame 预定位
2308
+ // setup 阶段:page 切换 + frame 预定位
2309
+ if (testCase.setup?.findPage) {
2310
+ const pageResult = await this._findPage(testCase.setup.findPage);
2311
+ if (!pageResult.found) {
2312
+ throw new Error(`findPage failed: ${pageResult.error} - available pages: ${JSON.stringify(pageResult.pages?.map(p => p.url))}`);
2313
+ }
2314
+ // page 切换后需要重新检测 wujie 模式
2315
+ this._wujieContext = await this._detectWujieMode();
2316
+ }
2270
2317
  if (testCase.setup?.findFrame && !this._activeFrame) {
2271
2318
  const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
2272
2319
  this._activeFrame = resolved.frame;
@@ -8,11 +8,12 @@
8
8
  * node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
9
9
  * node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
10
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 --action evaluate --expression "(() => ({a:1}))()" --frame-index 1
11
12
  * node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
12
13
  * node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
13
14
  *
14
15
  * 参数:
15
- * --action 直接执行动作,当前支持 observe/act/assert/api-capture
16
+ * --action 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
16
17
  * --plan 测试计划 JSON 字符串
17
18
  * --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
18
19
  * --output 结果输出文件路径(默认 stdout)
@@ -228,6 +229,15 @@ function parseArgs() {
228
229
  case "--test-report":
229
230
  parsed.testReport = args[++i];
230
231
  break;
232
+ case "--expression":
233
+ parsed.expression = args[++i];
234
+ break;
235
+ case "--expect-result-contains":
236
+ parsed.expectResultContains = args[++i];
237
+ break;
238
+ case "--fail-on-falsy":
239
+ parsed.failOnFalsy = true;
240
+ break;
231
241
  case "--help":
232
242
  case "-h":
233
243
  printHelp();
@@ -249,11 +259,12 @@ function printHelp() {
249
259
  node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
250
260
  node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
251
261
  node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
262
+ node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" [options]
252
263
  node local-browser-executor.js --plan '<JSON>' [options]
253
264
  node local-browser-executor.js --plan-file <path> [options]
254
265
 
255
266
  选项:
256
- --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
267
+ --action <name> 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
257
268
  --plan <json> 测试计划 JSON 字符串
258
269
  --plan-file <path> 测试计划 JSON 文件路径
259
270
  --output <path> 结果输出文件路径(默认 stdout)
@@ -274,6 +285,9 @@ function printHelp() {
274
285
  --menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
275
286
  --wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
276
287
  --observe-after act 后再次 observe,返回 before/act/after 闭环结果
288
+ --expression <js> evaluate 时执行的 JS 表达式(需用 IIFE 包裹)
289
+ --expect-result-contains <text> evaluate 时期望结果包含的文本
290
+ --fail-on-falsy evaluate 时结果为 falsy 则标记失败
277
291
  --url-contains-assert <text> assert 当前 URL 包含文本
278
292
  --text-contains <text> assert 页面文本包含内容
279
293
  --expect-visible assert selector 可见
@@ -493,6 +507,34 @@ async function executeAction(args) {
493
507
  });
494
508
  return { type: "actObserve", act: actResult, after, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
495
509
  }
510
+ case "evaluate": {
511
+ // 先通过 _resolveObserveFrame 设置 _activeFrame,使 safeEval 在正确的 frame 中执行
512
+ const resolved = await engine._resolveObserveFrame({
513
+ frameUrlContains: args.frameUrlContains,
514
+ frameContentContains: args.frameContentContains,
515
+ frameIndex: args.frameIndex,
516
+ });
517
+ engine._activeFrame = resolved.frame;
518
+ const evalResult = await engine.safeEval(args.expression);
519
+ const evalPassed = args.failOnFalsy ? !!evalResult : true;
520
+ const containsMatch = args.expectResultContains
521
+ ? JSON.stringify(evalResult).includes(args.expectResultContains)
522
+ : true;
523
+ return {
524
+ type: "evaluate",
525
+ expression: args.expression,
526
+ result: evalResult,
527
+ passed: evalPassed && containsMatch,
528
+ frameTarget: resolved.frameId,
529
+ checks: {
530
+ failOnFalsy: args.failOnFalsy ? { expected: "truthy", actual: !!evalResult, passed: !!evalResult } : null,
531
+ expectResultContains: args.expectResultContains ? { expected: args.expectResultContains, passed: containsMatch } : null,
532
+ },
533
+ browserInfo: initResult,
534
+ startTime: startedAt,
535
+ endTime: new Date().toISOString(),
536
+ };
537
+ }
496
538
  case "assert": {
497
539
  const expect = {};
498
540
  if (args.expectVisible) expect.visible = true;
@@ -509,6 +551,11 @@ async function executeAction(args) {
509
551
  });
510
552
  return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
511
553
  }
554
+ case "plan": {
555
+ const plan = loadPlan(args);
556
+ const result = await executePlan(plan, { cdpUrl: args.cdpUrl, pageUrl: args.pageUrl || args.urlContains });
557
+ return { type: "plan", ...result, startTime: startedAt, endTime: new Date().toISOString() };
558
+ }
512
559
  case "api-capture":
513
560
  case "apiCapture": {
514
561
  const result = await engine.captureApi({
@@ -628,7 +675,7 @@ async function executeAction(args) {
628
675
  return {
629
676
  error: `未知 action: ${args.action}`,
630
677
  errorCode: "UNKNOWN_ACTION",
631
- supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
678
+ supportedActions: ["observe", "act", "assert", "evaluate", "api-capture", "assert-api", "run-task", "test-case", "plan"],
632
679
  startTime: startedAt,
633
680
  endTime: new Date().toISOString(),
634
681
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@szc-ft/mcp-szcd-client",
3
- "version": "0.29.0",
3
+ "version": "0.30.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.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "szcd 组件库 MCP 助手 — 查询组件信息、匹配需求、生成代码",
5
5
  "mcpServers": {
6
6
  "szcd-component-helper": {
@@ -376,7 +376,7 @@ node "$EXECUTOR" \
376
376
  --output /tmp/browser-observe.json
377
377
  ```
378
378
 
379
- `observe` 输出 `frames`、`tree`、`interactiveElements[index]`、`selectorCandidates`、`bbox`、`state`。Agent 后续应优先使用 `index` 调用 `act`,不要从 DOM 树重新猜 selector。直接 `--action observe` 默认只连接用户已启动的真实浏览器 CDP;如确需无 CDP 时启动 headless 回退,追加 `--allow-launch`。
379
+ `observe` 输出 `frames`、`tree`、`interactiveElements[index]`、`selectorCandidates`、`bbox`、`state`、`wujieContext`(含 `shadowBodyText` 全文 + `shadowBodyTextLength`)。Agent 后续应优先使用 `index` 调用 `act`,不要从 DOM 树重新猜 selector。直接 `--action observe` 默认只连接用户已启动的真实浏览器 CDP;如确需无 CDP 时启动 headless 回退,追加 `--allow-launch`。
380
380
 
381
381
  ### 观察后按 index 交互(主链路闭环)
382
382
  ```bash
@@ -440,6 +440,38 @@ node "$EXECUTOR" \
440
440
 
441
441
  计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
442
442
 
443
+ ### 浏览器端 JS 执行(构造 testCase 前提取表单元数据)
444
+ ```bash
445
+ node "$EXECUTOR" \
446
+ --action evaluate \
447
+ --url-contains "platform.aicityos.com" \
448
+ --frame-index 1 \
449
+ --expression '(() => Array.from(document.querySelectorAll(".ant-form-item")).map(item => ({
450
+ label: item.querySelector(".ant-form-item-label")?.textContent?.trim() || "",
451
+ required: !!item.querySelector(".ant-form-item-required"),
452
+ error: item.querySelector(".ant-form-item-explain-error")?.textContent?.trim() || ""
453
+ })).filter(x => x.label))()' \
454
+ --fail-on-falsy \
455
+ --output /tmp/form-fields.json
456
+ ```
457
+
458
+ `--action evaluate` 在目标 frame 中执行任意 JS 表达式(需用 IIFE 包裹),返回 `{ result, passed }`。这是构造 testCase JSON 前的核心步骤:**无源码依赖地提取页面表单元数据、校验错误、Select 选项列表等**。
459
+
460
+ 关键参数:
461
+ - `--expression <js>`:执行的 JS 表达式(必填)
462
+ - `--fail-on-falsy`:结果为 falsy 时标记 FAIL
463
+ - `--expect-result-contains <text>`:结果 JSON 包含指定文本
464
+
465
+ ### 执行测试计划 JSON(ES Module 版)
466
+ ```bash
467
+ node "$EXECUTOR" \
468
+ --action plan \
469
+ --plan-file /tmp/test-plan.json \
470
+ --output /tmp/browser-test-result.json
471
+ ```
472
+
473
+ `--action plan` 使用 ES Module 执行器运行测试计划 JSON,支持全部步骤类型(包括 evaluate、antSelect 等)。与 `--action test-case` 的区别:plan 不走 dataset 循环,适合单次执行。
474
+
443
475
  ### 快速诊断(过渡兼容)
444
476
  ```bash
445
477
  NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/local-browser-executor.cjs" \
@@ -526,7 +558,8 @@ NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/local-browser-executor.
526
558
  --scroll down --scroll-amount 500
527
559
  ```
528
560
  选择器语法:
529
- - `text=Build` → 按文本内容查找(类似 Playwright text selector)
561
+ - `text=Build` → 按文本内容模糊查找,多个匹配时选 innerText 最短的元素(叶子节点优先,避免命中父容器)
562
+ - `text==Build` → 精确文本匹配(Playwright 风格),只匹配 innerText 完全相等的元素
530
563
  - `role=button` → 按 ARIA role 查找
531
564
  - `~ant-menu-item` → CSS class 模糊匹配(`[class*="ant-menu-item"]`)
532
565
  - `.my-class` → 精确 CSS 选择器
@@ -81,27 +81,32 @@ export class BrowserEngine {
81
81
  const frames = this.page.frames();
82
82
  const frameCount = frames.length;
83
83
  const mainFrame = this.page.mainFrame();
84
+ // JSON 输出返回完整文本不截断;stderr 日志仅截取前 200 字符预览
85
+ const LOG_PREVIEW_LEN = 200;
84
86
 
85
87
  // 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
86
88
  const wujieInfo = await mainFrame.evaluate(() => {
87
89
  const app = document.querySelector('wujie-app');
88
90
  if (!app) return null;
89
91
  const sr = app.shadowRoot;
92
+ const body = sr?.querySelector('body');
93
+ const fullText = body?.innerText || '';
90
94
  return {
91
95
  tagName: app.tagName,
92
96
  className: app.className,
93
97
  hasShadowRoot: !!sr,
94
98
  shadowChildren: sr?.children?.length || 0,
95
- shadowBodyText: sr?.querySelector('body')?.innerText?.substring(0, 80) || ''
99
+ shadowBodyText: fullText,
100
+ shadowBodyTextLength: fullText.length,
96
101
  };
97
102
  }).catch(() => null);
98
103
 
99
104
  if (wujieInfo) {
100
105
  const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
101
106
  process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
102
- if (wujieInfo.shadowBodyText) {
103
- process.stderr.write(`[wujie] shadow body preview: "${wujieInfo.shadowBodyText}"\n`);
104
- }
107
+ // stderr 只输出预览(不刷屏),JSON 中返回全文
108
+ const preview = wujieInfo.shadowBodyText?.substring(0, LOG_PREVIEW_LEN) || '';
109
+ process.stderr.write(`[wujie] shadow body (${wujieInfo.shadowBodyTextLength} chars): "${preview}${wujieInfo.shadowBodyTextLength > LOG_PREVIEW_LEN ? '...' : ''}"\n`);
105
110
  // 列出所有 frame 供排障
106
111
  for (let i = 0; i < frames.length; i++) {
107
112
  process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
@@ -443,17 +448,36 @@ export class BrowserEngine {
443
448
 
444
449
  function findBySelector(selector) {
445
450
  if (!selector) return null;
451
+ // text== 精确匹配(Playwright 兼容):只匹配 innerText 完全相等的元素
452
+ if (selector.startsWith("text==")) {
453
+ const expected = selector.slice(6).trim();
454
+ const filter = (el) => isVisibleEnabled(el);
455
+ const interactive = interactiveElements().filter(filter);
456
+ let hit = interactive.find((el) => getText(el) === expected || getName(el, getText(el)) === expected);
457
+ if (hit) return hit;
458
+ const all = Array.from(document.querySelectorAll("*")).filter(filter);
459
+ return all.find((el) => getText(el) === expected) || null;
460
+ }
461
+ // text= 模糊匹配:优先选 innerText 最短的元素(离目标文字最近的叶子节点)
462
+ // 避免命中包含目标文字的大容器(如 drawer 整体 div)
446
463
  if (selector.startsWith("text=")) {
447
464
  const expected = selector.slice(5).trim();
448
- // 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
449
- // 需要包含隐藏元素时显式 includeHidden=true
450
465
  const includeHidden = actOptions.includeHidden === true;
451
466
  const filter = (el) => includeHidden || isVisibleEnabled(el);
452
467
  const interactive = interactiveElements().filter(filter);
453
- let hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
454
- if (hit) return hit;
468
+ let hits = interactive.filter((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
469
+ if (hits.length > 0) {
470
+ // 按 innerText 长度升序:最短的最可能是目标元素本身而非父容器
471
+ hits.sort((a, b) => getText(a).length - getText(b).length);
472
+ return hits[0];
473
+ }
455
474
  const all = Array.from(document.querySelectorAll("*")).filter(filter);
456
- return all.find((el) => getText(el).includes(expected)) || null;
475
+ hits = all.filter((el) => getText(el).includes(expected));
476
+ if (hits.length > 0) {
477
+ hits.sort((a, b) => getText(a).length - getText(b).length);
478
+ return hits[0];
479
+ }
480
+ return null;
457
481
  }
458
482
  if (selector.startsWith("role=")) {
459
483
  const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
@@ -1249,8 +1273,23 @@ export class BrowserEngine {
1249
1273
 
1250
1274
  const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
1251
1275
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
1252
- return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1253
- .filter(isCandidate)
1276
+
1277
+ // 1. 先收集 portal 弹窗/抽屉中的元素(排在前面,避免 text= 匹配到外层容器)
1278
+ const portalSelectors = '.ant-drawer:not(.ant-drawer-closed), .ant-drawer-open .ant-drawer-content-wrapper, .ant-modal-wrap:not([style*="display: none"]), .ant-dropdown:not(.ant-dropdown-hidden)';
1279
+ const portalEls = [];
1280
+ document.querySelectorAll(portalSelectors).forEach(portal => {
1281
+ portal.querySelectorAll('a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*="btn"],[class*="button"],[class*="ant-btn"]').forEach(el => {
1282
+ if (isCandidate(el)) portalEls.push(el);
1283
+ });
1284
+ });
1285
+
1286
+ // 2. 再收集 body 中的元素,但要排除已在 portal 中的(避免重复)
1287
+ const portalSet = new Set(portalEls);
1288
+ const bodyEls = Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1289
+ .filter(el => isCandidate(el) && !portalSet.has(el));
1290
+
1291
+ // portal 元素排前面(优先级更高),body 元素排后面
1292
+ return portalEls.concat(bodyEls)
1254
1293
  .slice(0, limit)
1255
1294
  .map((el, idx) => {
1256
1295
  const rect = el.getBoundingClientRect();
@@ -2266,7 +2305,15 @@ export class BrowserEngine {
2266
2305
  const results = [];
2267
2306
  const dataset = testCase.dataset || [{ __row: 0 }];
2268
2307
 
2269
- // setup 阶段:frame 预定位
2308
+ // setup 阶段:page 切换 + frame 预定位
2309
+ if (testCase.setup?.findPage) {
2310
+ const pageResult = await this._findPage(testCase.setup.findPage);
2311
+ if (!pageResult.found) {
2312
+ throw new Error(`findPage failed: ${pageResult.error} - available pages: ${JSON.stringify(pageResult.pages?.map(p => p.url))}`);
2313
+ }
2314
+ // page 切换后需要重新检测 wujie 模式
2315
+ this._wujieContext = await this._detectWujieMode();
2316
+ }
2270
2317
  if (testCase.setup?.findFrame && !this._activeFrame) {
2271
2318
  const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
2272
2319
  this._activeFrame = resolved.frame;
@@ -8,11 +8,12 @@
8
8
  * node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
9
9
  * node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
10
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 --action evaluate --expression "(() => ({a:1}))()" --frame-index 1
11
12
  * node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
12
13
  * node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
13
14
  *
14
15
  * 参数:
15
- * --action 直接执行动作,当前支持 observe/act/assert/api-capture
16
+ * --action 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
16
17
  * --plan 测试计划 JSON 字符串
17
18
  * --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
18
19
  * --output 结果输出文件路径(默认 stdout)
@@ -228,6 +229,15 @@ function parseArgs() {
228
229
  case "--test-report":
229
230
  parsed.testReport = args[++i];
230
231
  break;
232
+ case "--expression":
233
+ parsed.expression = args[++i];
234
+ break;
235
+ case "--expect-result-contains":
236
+ parsed.expectResultContains = args[++i];
237
+ break;
238
+ case "--fail-on-falsy":
239
+ parsed.failOnFalsy = true;
240
+ break;
231
241
  case "--help":
232
242
  case "-h":
233
243
  printHelp();
@@ -249,11 +259,12 @@ function printHelp() {
249
259
  node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
250
260
  node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
251
261
  node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
262
+ node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" [options]
252
263
  node local-browser-executor.js --plan '<JSON>' [options]
253
264
  node local-browser-executor.js --plan-file <path> [options]
254
265
 
255
266
  选项:
256
- --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
267
+ --action <name> 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
257
268
  --plan <json> 测试计划 JSON 字符串
258
269
  --plan-file <path> 测试计划 JSON 文件路径
259
270
  --output <path> 结果输出文件路径(默认 stdout)
@@ -274,6 +285,9 @@ function printHelp() {
274
285
  --menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
275
286
  --wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
276
287
  --observe-after act 后再次 observe,返回 before/act/after 闭环结果
288
+ --expression <js> evaluate 时执行的 JS 表达式(需用 IIFE 包裹)
289
+ --expect-result-contains <text> evaluate 时期望结果包含的文本
290
+ --fail-on-falsy evaluate 时结果为 falsy 则标记失败
277
291
  --url-contains-assert <text> assert 当前 URL 包含文本
278
292
  --text-contains <text> assert 页面文本包含内容
279
293
  --expect-visible assert selector 可见
@@ -493,6 +507,34 @@ async function executeAction(args) {
493
507
  });
494
508
  return { type: "actObserve", act: actResult, after, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
495
509
  }
510
+ case "evaluate": {
511
+ // 先通过 _resolveObserveFrame 设置 _activeFrame,使 safeEval 在正确的 frame 中执行
512
+ const resolved = await engine._resolveObserveFrame({
513
+ frameUrlContains: args.frameUrlContains,
514
+ frameContentContains: args.frameContentContains,
515
+ frameIndex: args.frameIndex,
516
+ });
517
+ engine._activeFrame = resolved.frame;
518
+ const evalResult = await engine.safeEval(args.expression);
519
+ const evalPassed = args.failOnFalsy ? !!evalResult : true;
520
+ const containsMatch = args.expectResultContains
521
+ ? JSON.stringify(evalResult).includes(args.expectResultContains)
522
+ : true;
523
+ return {
524
+ type: "evaluate",
525
+ expression: args.expression,
526
+ result: evalResult,
527
+ passed: evalPassed && containsMatch,
528
+ frameTarget: resolved.frameId,
529
+ checks: {
530
+ failOnFalsy: args.failOnFalsy ? { expected: "truthy", actual: !!evalResult, passed: !!evalResult } : null,
531
+ expectResultContains: args.expectResultContains ? { expected: args.expectResultContains, passed: containsMatch } : null,
532
+ },
533
+ browserInfo: initResult,
534
+ startTime: startedAt,
535
+ endTime: new Date().toISOString(),
536
+ };
537
+ }
496
538
  case "assert": {
497
539
  const expect = {};
498
540
  if (args.expectVisible) expect.visible = true;
@@ -509,6 +551,11 @@ async function executeAction(args) {
509
551
  });
510
552
  return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
511
553
  }
554
+ case "plan": {
555
+ const plan = loadPlan(args);
556
+ const result = await executePlan(plan, { cdpUrl: args.cdpUrl, pageUrl: args.pageUrl || args.urlContains });
557
+ return { type: "plan", ...result, startTime: startedAt, endTime: new Date().toISOString() };
558
+ }
512
559
  case "api-capture":
513
560
  case "apiCapture": {
514
561
  const result = await engine.captureApi({
@@ -628,7 +675,7 @@ async function executeAction(args) {
628
675
  return {
629
676
  error: `未知 action: ${args.action}`,
630
677
  errorCode: "UNKNOWN_ACTION",
631
- supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
678
+ supportedActions: ["observe", "act", "assert", "evaluate", "api-capture", "assert-api", "run-task", "test-case", "plan"],
632
679
  startTime: startedAt,
633
680
  endTime: new Date().toISOString(),
634
681
  };
@@ -376,7 +376,7 @@ node "$EXECUTOR" \
376
376
  --output /tmp/browser-observe.json
377
377
  ```
378
378
 
379
- `observe` 输出 `frames`、`tree`、`interactiveElements[index]`、`selectorCandidates`、`bbox`、`state`。Agent 后续应优先使用 `index` 调用 `act`,不要从 DOM 树重新猜 selector。直接 `--action observe` 默认只连接用户已启动的真实浏览器 CDP;如确需无 CDP 时启动 headless 回退,追加 `--allow-launch`。
379
+ `observe` 输出 `frames`、`tree`、`interactiveElements[index]`、`selectorCandidates`、`bbox`、`state`、`wujieContext`(含 `shadowBodyText` 全文 + `shadowBodyTextLength`)。Agent 后续应优先使用 `index` 调用 `act`,不要从 DOM 树重新猜 selector。直接 `--action observe` 默认只连接用户已启动的真实浏览器 CDP;如确需无 CDP 时启动 headless 回退,追加 `--allow-launch`。
380
380
 
381
381
  ### 观察后按 index 交互(主链路闭环)
382
382
  ```bash
@@ -440,6 +440,38 @@ node "$EXECUTOR" \
440
440
 
441
441
  计划内可直接在 `apiCapture` 后追加 `{ "type": "assertApi", "maxFailed": 0, "forbidStatusGte": 400 }`,会自动读取上一步捕获结果。
442
442
 
443
+ ### 浏览器端 JS 执行(构造 testCase 前提取表单元数据)
444
+ ```bash
445
+ node "$EXECUTOR" \
446
+ --action evaluate \
447
+ --url-contains "platform.aicityos.com" \
448
+ --frame-index 1 \
449
+ --expression '(() => Array.from(document.querySelectorAll(".ant-form-item")).map(item => ({
450
+ label: item.querySelector(".ant-form-item-label")?.textContent?.trim() || "",
451
+ required: !!item.querySelector(".ant-form-item-required"),
452
+ error: item.querySelector(".ant-form-item-explain-error")?.textContent?.trim() || ""
453
+ })).filter(x => x.label))()' \
454
+ --fail-on-falsy \
455
+ --output /tmp/form-fields.json
456
+ ```
457
+
458
+ `--action evaluate` 在目标 frame 中执行任意 JS 表达式(需用 IIFE 包裹),返回 `{ result, passed }`。这是构造 testCase JSON 前的核心步骤:**无源码依赖地提取页面表单元数据、校验错误、Select 选项列表等**。
459
+
460
+ 关键参数:
461
+ - `--expression <js>`:执行的 JS 表达式(必填)
462
+ - `--fail-on-falsy`:结果为 falsy 时标记 FAIL
463
+ - `--expect-result-contains <text>`:结果 JSON 包含指定文本
464
+
465
+ ### 执行测试计划 JSON(ES Module 版)
466
+ ```bash
467
+ node "$EXECUTOR" \
468
+ --action plan \
469
+ --plan-file /tmp/test-plan.json \
470
+ --output /tmp/browser-test-result.json
471
+ ```
472
+
473
+ `--action plan` 使用 ES Module 执行器运行测试计划 JSON,支持全部步骤类型(包括 evaluate、antSelect 等)。与 `--action test-case` 的区别:plan 不走 dataset 循环,适合单次执行。
474
+
443
475
  ### 快速诊断(过渡兼容)
444
476
  ```bash
445
477
  NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/local-browser-executor.cjs" \
@@ -526,7 +558,8 @@ NODE_PATH=~/.szcd-mcp/deps/node_modules node "$SKILL_DIR/local-browser-executor.
526
558
  --scroll down --scroll-amount 500
527
559
  ```
528
560
  选择器语法:
529
- - `text=Build` → 按文本内容查找(类似 Playwright text selector)
561
+ - `text=Build` → 按文本内容模糊查找,多个匹配时选 innerText 最短的元素(叶子节点优先,避免命中父容器)
562
+ - `text==Build` → 精确文本匹配(Playwright 风格),只匹配 innerText 完全相等的元素
530
563
  - `role=button` → 按 ARIA role 查找
531
564
  - `~ant-menu-item` → CSS class 模糊匹配(`[class*="ant-menu-item"]`)
532
565
  - `.my-class` → 精确 CSS 选择器
@@ -81,27 +81,32 @@ export class BrowserEngine {
81
81
  const frames = this.page.frames();
82
82
  const frameCount = frames.length;
83
83
  const mainFrame = this.page.mainFrame();
84
+ // JSON 输出返回完整文本不截断;stderr 日志仅截取前 200 字符预览
85
+ const LOG_PREVIEW_LEN = 200;
84
86
 
85
87
  // 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
86
88
  const wujieInfo = await mainFrame.evaluate(() => {
87
89
  const app = document.querySelector('wujie-app');
88
90
  if (!app) return null;
89
91
  const sr = app.shadowRoot;
92
+ const body = sr?.querySelector('body');
93
+ const fullText = body?.innerText || '';
90
94
  return {
91
95
  tagName: app.tagName,
92
96
  className: app.className,
93
97
  hasShadowRoot: !!sr,
94
98
  shadowChildren: sr?.children?.length || 0,
95
- shadowBodyText: sr?.querySelector('body')?.innerText?.substring(0, 80) || ''
99
+ shadowBodyText: fullText,
100
+ shadowBodyTextLength: fullText.length,
96
101
  };
97
102
  }).catch(() => null);
98
103
 
99
104
  if (wujieInfo) {
100
105
  const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
101
106
  process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
102
- if (wujieInfo.shadowBodyText) {
103
- process.stderr.write(`[wujie] shadow body preview: "${wujieInfo.shadowBodyText}"\n`);
104
- }
107
+ // stderr 只输出预览(不刷屏),JSON 中返回全文
108
+ const preview = wujieInfo.shadowBodyText?.substring(0, LOG_PREVIEW_LEN) || '';
109
+ process.stderr.write(`[wujie] shadow body (${wujieInfo.shadowBodyTextLength} chars): "${preview}${wujieInfo.shadowBodyTextLength > LOG_PREVIEW_LEN ? '...' : ''}"\n`);
105
110
  // 列出所有 frame 供排障
106
111
  for (let i = 0; i < frames.length; i++) {
107
112
  process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
@@ -443,17 +448,36 @@ export class BrowserEngine {
443
448
 
444
449
  function findBySelector(selector) {
445
450
  if (!selector) return null;
451
+ // text== 精确匹配(Playwright 兼容):只匹配 innerText 完全相等的元素
452
+ if (selector.startsWith("text==")) {
453
+ const expected = selector.slice(6).trim();
454
+ const filter = (el) => isVisibleEnabled(el);
455
+ const interactive = interactiveElements().filter(filter);
456
+ let hit = interactive.find((el) => getText(el) === expected || getName(el, getText(el)) === expected);
457
+ if (hit) return hit;
458
+ const all = Array.from(document.querySelectorAll("*")).filter(filter);
459
+ return all.find((el) => getText(el) === expected) || null;
460
+ }
461
+ // text= 模糊匹配:优先选 innerText 最短的元素(离目标文字最近的叶子节点)
462
+ // 避免命中包含目标文字的大容器(如 drawer 整体 div)
446
463
  if (selector.startsWith("text=")) {
447
464
  const expected = selector.slice(5).trim();
448
- // 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
449
- // 需要包含隐藏元素时显式 includeHidden=true
450
465
  const includeHidden = actOptions.includeHidden === true;
451
466
  const filter = (el) => includeHidden || isVisibleEnabled(el);
452
467
  const interactive = interactiveElements().filter(filter);
453
- let hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
454
- if (hit) return hit;
468
+ let hits = interactive.filter((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
469
+ if (hits.length > 0) {
470
+ // 按 innerText 长度升序:最短的最可能是目标元素本身而非父容器
471
+ hits.sort((a, b) => getText(a).length - getText(b).length);
472
+ return hits[0];
473
+ }
455
474
  const all = Array.from(document.querySelectorAll("*")).filter(filter);
456
- return all.find((el) => getText(el).includes(expected)) || null;
475
+ hits = all.filter((el) => getText(el).includes(expected));
476
+ if (hits.length > 0) {
477
+ hits.sort((a, b) => getText(a).length - getText(b).length);
478
+ return hits[0];
479
+ }
480
+ return null;
457
481
  }
458
482
  if (selector.startsWith("role=")) {
459
483
  const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
@@ -1249,8 +1273,23 @@ export class BrowserEngine {
1249
1273
 
1250
1274
  const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
1251
1275
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
1252
- return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1253
- .filter(isCandidate)
1276
+
1277
+ // 1. 先收集 portal 弹窗/抽屉中的元素(排在前面,避免 text= 匹配到外层容器)
1278
+ const portalSelectors = '.ant-drawer:not(.ant-drawer-closed), .ant-drawer-open .ant-drawer-content-wrapper, .ant-modal-wrap:not([style*="display: none"]), .ant-dropdown:not(.ant-dropdown-hidden)';
1279
+ const portalEls = [];
1280
+ document.querySelectorAll(portalSelectors).forEach(portal => {
1281
+ portal.querySelectorAll('a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*="btn"],[class*="button"],[class*="ant-btn"]').forEach(el => {
1282
+ if (isCandidate(el)) portalEls.push(el);
1283
+ });
1284
+ });
1285
+
1286
+ // 2. 再收集 body 中的元素,但要排除已在 portal 中的(避免重复)
1287
+ const portalSet = new Set(portalEls);
1288
+ const bodyEls = Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1289
+ .filter(el => isCandidate(el) && !portalSet.has(el));
1290
+
1291
+ // portal 元素排前面(优先级更高),body 元素排后面
1292
+ return portalEls.concat(bodyEls)
1254
1293
  .slice(0, limit)
1255
1294
  .map((el, idx) => {
1256
1295
  const rect = el.getBoundingClientRect();
@@ -2266,7 +2305,15 @@ export class BrowserEngine {
2266
2305
  const results = [];
2267
2306
  const dataset = testCase.dataset || [{ __row: 0 }];
2268
2307
 
2269
- // setup 阶段:frame 预定位
2308
+ // setup 阶段:page 切换 + frame 预定位
2309
+ if (testCase.setup?.findPage) {
2310
+ const pageResult = await this._findPage(testCase.setup.findPage);
2311
+ if (!pageResult.found) {
2312
+ throw new Error(`findPage failed: ${pageResult.error} - available pages: ${JSON.stringify(pageResult.pages?.map(p => p.url))}`);
2313
+ }
2314
+ // page 切换后需要重新检测 wujie 模式
2315
+ this._wujieContext = await this._detectWujieMode();
2316
+ }
2270
2317
  if (testCase.setup?.findFrame && !this._activeFrame) {
2271
2318
  const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
2272
2319
  this._activeFrame = resolved.frame;
@@ -8,11 +8,12 @@
8
8
  * node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
9
9
  * node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
10
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 --action evaluate --expression "(() => ({a:1}))()" --frame-index 1
11
12
  * node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
12
13
  * node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
13
14
  *
14
15
  * 参数:
15
- * --action 直接执行动作,当前支持 observe/act/assert/api-capture
16
+ * --action 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
16
17
  * --plan 测试计划 JSON 字符串
17
18
  * --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
18
19
  * --output 结果输出文件路径(默认 stdout)
@@ -228,6 +229,15 @@ function parseArgs() {
228
229
  case "--test-report":
229
230
  parsed.testReport = args[++i];
230
231
  break;
232
+ case "--expression":
233
+ parsed.expression = args[++i];
234
+ break;
235
+ case "--expect-result-contains":
236
+ parsed.expectResultContains = args[++i];
237
+ break;
238
+ case "--fail-on-falsy":
239
+ parsed.failOnFalsy = true;
240
+ break;
231
241
  case "--help":
232
242
  case "-h":
233
243
  printHelp();
@@ -249,11 +259,12 @@ function printHelp() {
249
259
  node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
250
260
  node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
251
261
  node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
262
+ node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" [options]
252
263
  node local-browser-executor.js --plan '<JSON>' [options]
253
264
  node local-browser-executor.js --plan-file <path> [options]
254
265
 
255
266
  选项:
256
- --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
267
+ --action <name> 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
257
268
  --plan <json> 测试计划 JSON 字符串
258
269
  --plan-file <path> 测试计划 JSON 文件路径
259
270
  --output <path> 结果输出文件路径(默认 stdout)
@@ -274,6 +285,9 @@ function printHelp() {
274
285
  --menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
275
286
  --wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
276
287
  --observe-after act 后再次 observe,返回 before/act/after 闭环结果
288
+ --expression <js> evaluate 时执行的 JS 表达式(需用 IIFE 包裹)
289
+ --expect-result-contains <text> evaluate 时期望结果包含的文本
290
+ --fail-on-falsy evaluate 时结果为 falsy 则标记失败
277
291
  --url-contains-assert <text> assert 当前 URL 包含文本
278
292
  --text-contains <text> assert 页面文本包含内容
279
293
  --expect-visible assert selector 可见
@@ -493,6 +507,34 @@ async function executeAction(args) {
493
507
  });
494
508
  return { type: "actObserve", act: actResult, after, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
495
509
  }
510
+ case "evaluate": {
511
+ // 先通过 _resolveObserveFrame 设置 _activeFrame,使 safeEval 在正确的 frame 中执行
512
+ const resolved = await engine._resolveObserveFrame({
513
+ frameUrlContains: args.frameUrlContains,
514
+ frameContentContains: args.frameContentContains,
515
+ frameIndex: args.frameIndex,
516
+ });
517
+ engine._activeFrame = resolved.frame;
518
+ const evalResult = await engine.safeEval(args.expression);
519
+ const evalPassed = args.failOnFalsy ? !!evalResult : true;
520
+ const containsMatch = args.expectResultContains
521
+ ? JSON.stringify(evalResult).includes(args.expectResultContains)
522
+ : true;
523
+ return {
524
+ type: "evaluate",
525
+ expression: args.expression,
526
+ result: evalResult,
527
+ passed: evalPassed && containsMatch,
528
+ frameTarget: resolved.frameId,
529
+ checks: {
530
+ failOnFalsy: args.failOnFalsy ? { expected: "truthy", actual: !!evalResult, passed: !!evalResult } : null,
531
+ expectResultContains: args.expectResultContains ? { expected: args.expectResultContains, passed: containsMatch } : null,
532
+ },
533
+ browserInfo: initResult,
534
+ startTime: startedAt,
535
+ endTime: new Date().toISOString(),
536
+ };
537
+ }
496
538
  case "assert": {
497
539
  const expect = {};
498
540
  if (args.expectVisible) expect.visible = true;
@@ -509,6 +551,11 @@ async function executeAction(args) {
509
551
  });
510
552
  return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
511
553
  }
554
+ case "plan": {
555
+ const plan = loadPlan(args);
556
+ const result = await executePlan(plan, { cdpUrl: args.cdpUrl, pageUrl: args.pageUrl || args.urlContains });
557
+ return { type: "plan", ...result, startTime: startedAt, endTime: new Date().toISOString() };
558
+ }
512
559
  case "api-capture":
513
560
  case "apiCapture": {
514
561
  const result = await engine.captureApi({
@@ -628,7 +675,7 @@ async function executeAction(args) {
628
675
  return {
629
676
  error: `未知 action: ${args.action}`,
630
677
  errorCode: "UNKNOWN_ACTION",
631
- supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
678
+ supportedActions: ["observe", "act", "assert", "evaluate", "api-capture", "assert-api", "run-task", "test-case", "plan"],
632
679
  startTime: startedAt,
633
680
  endTime: new Date().toISOString(),
634
681
  };