@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.
- package/opencode-extension/skills/local-browser-test/SKILL.md +35 -2
- package/opencode-extension/skills/local-browser-test/lib/browser-engine.js +59 -12
- package/opencode-extension/skills/local-browser-test/local-browser-executor.js +50 -3
- package/package.json +1 -1
- package/qwen-extension/qwen-extension.json +1 -1
- package/qwen-extension/skills/local-browser-test/SKILL.md +35 -2
- package/qwen-extension/skills/local-browser-test/lib/browser-engine.js +59 -12
- package/qwen-extension/skills/local-browser-test/local-browser-executor.js +50 -3
- package/standard-skill/local-browser-test/SKILL.md +35 -2
- package/standard-skill/local-browser-test/lib/browser-engine.js +59 -12
- package/standard-skill/local-browser-test/local-browser-executor.js +50 -3
|
@@ -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
|
|
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` →
|
|
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:
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
454
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
@@ -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
|
|
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` →
|
|
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:
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
454
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
|
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` →
|
|
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:
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|
454
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
};
|