dsh-browser-view 0.7.5 → 0.8.1
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/README.md +40 -7
- package/lib/index.js +1010 -205
- package/lib/mcp.js +10507 -0
- package/lib/types/browser/primitives/dialog.d.ts +21 -4
- package/lib/types/browser/primitives/drag.d.ts +12 -2
- package/lib/types/browser/primitives/index.d.ts +1 -0
- package/lib/types/browser/primitives/ui-library.d.ts +72 -0
- package/lib/types/browser/session/cache.d.ts +12 -0
- package/lib/types/browser/session/collaboration.d.ts +3 -0
- package/lib/types/browser/tools/common.d.ts +14 -1
- package/lib/types/browser/tools/interaction.d.ts +17 -2
- package/lib/types/browser/walker.d.ts +10 -0
- package/lib/types/lib/prompts.d.ts +7 -0
- package/lib/types/lib/schema.d.ts +34 -0
- package/lib/types/mcp.d.ts +29 -0
- package/package.json +17 -2
package/lib/index.js
CHANGED
|
@@ -47,6 +47,150 @@ function resolveConfig(moduleName, schema, defaults, raw) {
|
|
|
47
47
|
return config;
|
|
48
48
|
}
|
|
49
49
|
//#endregion
|
|
50
|
+
//#region lib/types/lib/schema.js
|
|
51
|
+
const BROWSER_CONFIG_SCHEMA = {
|
|
52
|
+
enabled: {
|
|
53
|
+
type: "boolean",
|
|
54
|
+
optional: true
|
|
55
|
+
},
|
|
56
|
+
channel: { type: "string" },
|
|
57
|
+
executablePath: { type: "string" },
|
|
58
|
+
headless: { type: "boolean" },
|
|
59
|
+
userDataDir: { type: "string" },
|
|
60
|
+
profiles: { type: "any" },
|
|
61
|
+
screenshotDir: { type: "string" },
|
|
62
|
+
downloadDir: { type: "string" },
|
|
63
|
+
workspaceDir: { type: "string" },
|
|
64
|
+
screenshotMaxAgeDays: {
|
|
65
|
+
type: "number",
|
|
66
|
+
min: 0
|
|
67
|
+
},
|
|
68
|
+
screenshotMaxCount: {
|
|
69
|
+
type: "number",
|
|
70
|
+
min: 0
|
|
71
|
+
},
|
|
72
|
+
basePath: { type: "string" },
|
|
73
|
+
recordBodies: { type: "boolean" },
|
|
74
|
+
maxTextChars: {
|
|
75
|
+
type: "number",
|
|
76
|
+
min: 1
|
|
77
|
+
},
|
|
78
|
+
maxLinks: {
|
|
79
|
+
type: "number",
|
|
80
|
+
min: 1
|
|
81
|
+
},
|
|
82
|
+
timeoutMs: {
|
|
83
|
+
type: "number",
|
|
84
|
+
min: 1
|
|
85
|
+
},
|
|
86
|
+
baselineDir: { type: "string" },
|
|
87
|
+
selectorHealing: { type: "boolean" },
|
|
88
|
+
healTimeoutMs: {
|
|
89
|
+
type: "number",
|
|
90
|
+
min: 100
|
|
91
|
+
},
|
|
92
|
+
maxRefs: {
|
|
93
|
+
type: "number",
|
|
94
|
+
min: 1
|
|
95
|
+
},
|
|
96
|
+
maxRows: {
|
|
97
|
+
type: "number",
|
|
98
|
+
min: 1
|
|
99
|
+
},
|
|
100
|
+
snapshotHistory: {
|
|
101
|
+
type: "number",
|
|
102
|
+
min: 1,
|
|
103
|
+
max: 20
|
|
104
|
+
},
|
|
105
|
+
confirmMode: {
|
|
106
|
+
type: "string",
|
|
107
|
+
enum: [
|
|
108
|
+
"off",
|
|
109
|
+
"manual",
|
|
110
|
+
"auto"
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
confirmTimeoutMs: {
|
|
114
|
+
type: "number",
|
|
115
|
+
min: 1e3
|
|
116
|
+
},
|
|
117
|
+
sensitivePatterns: { type: "string[]" },
|
|
118
|
+
sensitiveTools: { type: "string[]" },
|
|
119
|
+
confirmPolicies: {
|
|
120
|
+
type: "any",
|
|
121
|
+
optional: true
|
|
122
|
+
},
|
|
123
|
+
humanize: {
|
|
124
|
+
type: "any",
|
|
125
|
+
optional: true
|
|
126
|
+
},
|
|
127
|
+
humanizeSpeed: {
|
|
128
|
+
type: "string",
|
|
129
|
+
enum: [
|
|
130
|
+
"slow",
|
|
131
|
+
"normal",
|
|
132
|
+
"fast"
|
|
133
|
+
],
|
|
134
|
+
optional: true
|
|
135
|
+
},
|
|
136
|
+
confirmInReplay: { type: "boolean" },
|
|
137
|
+
somFallback: { type: "boolean" },
|
|
138
|
+
refReanchor: { type: "boolean" },
|
|
139
|
+
toolset: {
|
|
140
|
+
type: "string",
|
|
141
|
+
enum: ["core", "all"]
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const BROWSER_DEFAULT_CONFIG = {
|
|
145
|
+
channel: "auto",
|
|
146
|
+
executablePath: "",
|
|
147
|
+
headless: false,
|
|
148
|
+
userDataDir: "",
|
|
149
|
+
profiles: {},
|
|
150
|
+
screenshotDir: ".dsh-browser/screenshots",
|
|
151
|
+
downloadDir: ".dsh-browser/downloads",
|
|
152
|
+
workspaceDir: "",
|
|
153
|
+
screenshotMaxAgeDays: 7,
|
|
154
|
+
screenshotMaxCount: 200,
|
|
155
|
+
basePath: "/browser",
|
|
156
|
+
recordBodies: true,
|
|
157
|
+
maxTextChars: 2e4,
|
|
158
|
+
maxLinks: 50,
|
|
159
|
+
timeoutMs: 3e4,
|
|
160
|
+
baselineDir: ".dsh-browser/baselines",
|
|
161
|
+
selectorHealing: true,
|
|
162
|
+
healTimeoutMs: 3e3,
|
|
163
|
+
maxRefs: 200,
|
|
164
|
+
maxRows: 1e3,
|
|
165
|
+
snapshotHistory: 5,
|
|
166
|
+
confirmMode: "off",
|
|
167
|
+
confirmTimeoutMs: 6e4,
|
|
168
|
+
sensitivePatterns: [
|
|
169
|
+
"提交",
|
|
170
|
+
"删除",
|
|
171
|
+
"支付"
|
|
172
|
+
],
|
|
173
|
+
sensitiveTools: ["browser_click"],
|
|
174
|
+
confirmInReplay: true,
|
|
175
|
+
somFallback: true,
|
|
176
|
+
refReanchor: true,
|
|
177
|
+
toolset: "all"
|
|
178
|
+
};
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region lib/types/lib/prompts.js
|
|
181
|
+
const FULL_TOOL_PROMPT = "浏览器工具操作一个共享浏览器会话,支持多标签页与多配置文件。定位操作元素最省事的方式是意图语义定位:browser_click/browser_type/browser_select 直接传 what(元素名称/label,如 what:'登录'),页内打分匹配并自动注册 ref,一次调用替代 快照→查找→点击 三轮;零命中报错会附最相近候选。需要精确控制时再用 selector(CSS)/ref(aria 快照)。打开页面后可先 browser_snapshot 读文本与链接、browser_elements 读表单结构(含关联 label 与展开的下拉选项);browser_find(query 传名称,只返回命中 ref+区域上下文,大页面最省 token;零命中会给相近候选)适合多目标排查,需要全页结构时再用 browser_snapshot { mode: \"aria\" }(可交互元素带 ref=eN,click/type/select/press 直接传 ref 代替 CSS 选择器);下拉选择(原生 select 与 antd/element 组件库)用 browser_select 传 selector/ref/what + option 文本一步完成;固定小序列(点击→等待→输入→提交)用 browser_batch 一次执行减少往返;标签管理用 browser_tabs;下载用 browser_download(wait=true 可等新下载);登录态用 browser_cookies。做自动化测试时:用 browser_wait_for 显式等待页面稳定/元素出现,用 browser_assert 断言(失败自动截图;visual={baseline} 可做视觉回归,先用 browser_screenshot { baseline } 建基线),用 browser_network 检查接口调用与 4xx/5xx,跑完一轮可用 browser_record save 保存操作序列、browser_replay 回放做回归(选择器失效自动按指纹自愈;vars/data 支持参数化与数据驱动)。每次 replay/batch 都记入运行历史,browser_report list/export 可查看与导出回归报告。抓取结构化数据用 browser_extract(fields 定义 CSS 字段,可翻页,可导出 CSV/JSONL)。遇到需要登录的网站:先 browser_open 打开登录页,告知用户请在浏览器窗口中完成登录,再调用 browser_wait_for_login 等待(可给 successSelector/successUrl);验证码/二次确认等更广的「人补一步」场景用 browser_pause_for_human(提示语会显示在实时画面面板);配置 userDataDir 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。";
|
|
182
|
+
const CORE_TOOL_PROMPT = "浏览器工具操作一个共享浏览器会话(toolset=core:已精简工具集)。定位操作元素最省事的方式是意图语义定位:browser_click/browser_type/browser_select 直接传 what(元素名称/label,如 what:'登录'),一次调用替代快照→查找→点击;零命中报错附最相近候选。需要精确控制时再用 selector/ref:打开页面后用 browser_snapshot 读内容(scope 限定区域、since 增量重读省 token),browser_find(query 传名称,返回 ref),点击/输入/按键/下拉用 browser_click/browser_type/browser_press/browser_select(selector 或 ref);固定小序列用 browser_batch;等待用 browser_wait/browser_wait_for,断言用 browser_assert;接口检查用 browser_network;标签 browser_tabs;下载/上传 browser_download/browser_upload。需要人工:登录用 browser_wait_for_login,验证码/二次确认用 browser_pause_for_human,敏感动作(提交/删除/支付)前用 browser_confirm。环境异常先跑 browser_doctor 诊断。录制/回放/报告/抽取/profile/隔离 context 等工具当前未注册(toolset=core),需要时把配置改为 toolset: all 并重启 DSH。用户可通过界面右下角面板的实时画面查看浏览器。";
|
|
183
|
+
/** confirmMode 附加指引(与 DSH 入口同款语义,manual 面向宿主审批、auto 说明硬闸)。 */
|
|
184
|
+
function confirmGuidanceOf(confirmMode) {
|
|
185
|
+
if (confirmMode === "manual") return "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。";
|
|
186
|
+
if (confirmMode === "auto") return "本环境 confirmMode 为 auto:宿主层已在敏感动作(命中敏感模式的目标元素)前强制弹审批卡,agent 无法绕过;审批被拒绝或超时的动作不可换路径重试,应向用户说明并等待指示。";
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
/** 组装完整的工具使用指引文本(DSH systemPrompt 段落与 MCP instructions 共用)。 */
|
|
190
|
+
function browserSystemPrompt(config) {
|
|
191
|
+
return (config.toolset === "core" ? CORE_TOOL_PROMPT : FULL_TOOL_PROMPT) + confirmGuidanceOf(config.confirmMode);
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
50
194
|
//#region lib/types/lib/http.js
|
|
51
195
|
/** Parse the request URL pathname (query strings are ignored). */
|
|
52
196
|
function urlPath(req) {
|
|
@@ -390,6 +534,33 @@ function snapshotVersionEntry(cache, version) {
|
|
|
390
534
|
if (cache === void 0) return void 0;
|
|
391
535
|
return cache.history[cache.history.length - 1]?.version === version ? cache.history[cache.history.length - 1] : cache.history.find((e) => e.version === version);
|
|
392
536
|
}
|
|
537
|
+
const lastStableNavSeqs = /* @__PURE__ */ new WeakMap();
|
|
538
|
+
/** 记录"最近一次成功操作"的导航代际(pageIdentity 打点,零成本)。 */
|
|
539
|
+
function markStableNav(page) {
|
|
540
|
+
lastStableNavSeqs.set(page, pageNavSeq(page));
|
|
541
|
+
}
|
|
542
|
+
/** 当前代际距最近成功操作代际的差值(≥1 = 期间发生过导航)。 */
|
|
543
|
+
function navSinceStable(page) {
|
|
544
|
+
return pageNavSeq(page) - (lastStableNavSeqs.get(page) ?? pageNavSeq(page));
|
|
545
|
+
}
|
|
546
|
+
const externalNavs = /* @__PURE__ */ new WeakMap();
|
|
547
|
+
/** 记录一次疑似外部导航(非工具窗口内发生的 framenavigated)。 */
|
|
548
|
+
function noteExternalNav(page, url) {
|
|
549
|
+
const list = externalNavs.get(page) ?? [];
|
|
550
|
+
list.push({
|
|
551
|
+
url,
|
|
552
|
+
at: Date.now()
|
|
553
|
+
});
|
|
554
|
+
if (list.length > 5) list.shift();
|
|
555
|
+
externalNavs.set(page, list);
|
|
556
|
+
}
|
|
557
|
+
/** 读取并清空疑似外部导航记录,拼装 warning 文案;无记录返回 undefined。 */
|
|
558
|
+
function drainExternalNavWarning(page) {
|
|
559
|
+
const list = externalNavs.get(page);
|
|
560
|
+
if (list === void 0 || list.length === 0) return void 0;
|
|
561
|
+
externalNavs.delete(page);
|
|
562
|
+
return `检测到非工具触发的页面导航(页面自动跳转或人工操作:${list.map((n) => n.url.slice(0, 80)).join(" → ")}),此前的定位结果可能基于被切换后的页面,请核实当前页面状态`;
|
|
563
|
+
}
|
|
393
564
|
//#endregion
|
|
394
565
|
//#region lib/types/browser/session/collaboration.js
|
|
395
566
|
/**
|
|
@@ -397,6 +568,10 @@ function snapshotVersionEntry(cache, version) {
|
|
|
397
568
|
* 供实时画面面板展示「等待人工操作」提示。
|
|
398
569
|
*/
|
|
399
570
|
let pauseState = null;
|
|
571
|
+
let interactivePanelPresent = true;
|
|
572
|
+
function isInteractivePanelPresent() {
|
|
573
|
+
return interactivePanelPresent;
|
|
574
|
+
}
|
|
400
575
|
function setPauseInstruction(instruction) {
|
|
401
576
|
pauseState = {
|
|
402
577
|
instruction,
|
|
@@ -756,6 +931,53 @@ function injectWalker(entry) {
|
|
|
756
931
|
const src = `"use strict"; return function (payload) { var __name = (t, v) => t; const w = (${CORE_SRC})(); return (${entry.toString()})(payload, w); };`;
|
|
757
932
|
return new Function(src)();
|
|
758
933
|
}
|
|
934
|
+
const pickTopDialog = injectWalker((payload, w) => {
|
|
935
|
+
const effectiveZ = (el) => {
|
|
936
|
+
let node = el;
|
|
937
|
+
while (node !== null) {
|
|
938
|
+
const cs = getComputedStyle(node);
|
|
939
|
+
if (cs.position !== "static" && cs.zIndex !== "auto") {
|
|
940
|
+
const z = Number.parseInt(cs.zIndex, 10);
|
|
941
|
+
if (Number.isFinite(z)) return z;
|
|
942
|
+
}
|
|
943
|
+
node = w.composedParent(node);
|
|
944
|
+
}
|
|
945
|
+
return 0;
|
|
946
|
+
};
|
|
947
|
+
const visible = (el) => {
|
|
948
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
949
|
+
const cs = getComputedStyle(el);
|
|
950
|
+
if (cs.display === "none" || cs.visibility === "hidden") return false;
|
|
951
|
+
const r = el.getBoundingClientRect();
|
|
952
|
+
return r.width > 0 && r.height > 0;
|
|
953
|
+
};
|
|
954
|
+
const collect = (sel) => {
|
|
955
|
+
try {
|
|
956
|
+
return Array.from(document.querySelectorAll(sel)).filter(visible);
|
|
957
|
+
} catch {
|
|
958
|
+
return [];
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
let tier = "precise";
|
|
962
|
+
let nodes = collect(payload.precise);
|
|
963
|
+
if (nodes.length === 0) {
|
|
964
|
+
tier = "fuzzy";
|
|
965
|
+
nodes = collect(payload.fuzzy);
|
|
966
|
+
}
|
|
967
|
+
if (nodes.length === 0) return null;
|
|
968
|
+
const zs = nodes.map((n) => effectiveZ(n));
|
|
969
|
+
const maxZ = Math.max(...zs);
|
|
970
|
+
const top = nodes.filter((n, i) => zs[i] === maxZ);
|
|
971
|
+
const outermost = top.filter((n) => !top.some((m) => m !== n && m.contains(n)));
|
|
972
|
+
const pool = outermost.length > 0 ? outermost : top;
|
|
973
|
+
const winner = pool[pool.length - 1];
|
|
974
|
+
if (winner === void 0) return null;
|
|
975
|
+
return {
|
|
976
|
+
selector: w.cssPath(winner),
|
|
977
|
+
z: maxZ,
|
|
978
|
+
tier
|
|
979
|
+
};
|
|
980
|
+
});
|
|
759
981
|
//#endregion
|
|
760
982
|
//#region lib/types/browser/session/recorder.js
|
|
761
983
|
/** 已保存的录制脚本:name → 步骤数组。 */
|
|
@@ -1003,15 +1225,109 @@ function cleanupScreenshots(dir, opts) {
|
|
|
1003
1225
|
return removed;
|
|
1004
1226
|
}
|
|
1005
1227
|
//#endregion
|
|
1228
|
+
//#region lib/types/browser/primitives/ui-library.js
|
|
1229
|
+
const GENERIC = {
|
|
1230
|
+
dialogWords: [
|
|
1231
|
+
"modal",
|
|
1232
|
+
"drawer",
|
|
1233
|
+
"dialog"
|
|
1234
|
+
],
|
|
1235
|
+
dialogExcludedParts: [
|
|
1236
|
+
"mask",
|
|
1237
|
+
"wrap",
|
|
1238
|
+
"root",
|
|
1239
|
+
"header",
|
|
1240
|
+
"footer",
|
|
1241
|
+
"title",
|
|
1242
|
+
"close",
|
|
1243
|
+
"body",
|
|
1244
|
+
"btn"
|
|
1245
|
+
],
|
|
1246
|
+
/** 下拉触发器内部件(点它展开) */
|
|
1247
|
+
dropdownTriggerParts: "[class*='selector'], [role='combobox']",
|
|
1248
|
+
/** 下拉浮层容器的子串词(portal 挂载的面板) */
|
|
1249
|
+
dropdownFloatWords: ["select-dropdown", "popper"],
|
|
1250
|
+
/** 通用下拉选项行(select 状态机消费;库精确层另补) */
|
|
1251
|
+
dropdownOptionRows: "[class*='item-option']:not([class*='empty']), [class*='select-dropdown'] li:not([class*='empty'])",
|
|
1252
|
+
/** 可交互「选项」枚举(elements 工具下拉选项清单消费) */
|
|
1253
|
+
dropdownOptionEnumeration: [
|
|
1254
|
+
"[role='option']",
|
|
1255
|
+
"[role='menuitem']",
|
|
1256
|
+
"[class*='select'][class*='dropdown'] [class*='option']",
|
|
1257
|
+
"[class*='select'][class*='dropdown'] [class*='item']",
|
|
1258
|
+
"[role='listbox'] [role='option']"
|
|
1259
|
+
].join(", "),
|
|
1260
|
+
/** 多选标签清除按钮 */
|
|
1261
|
+
dropdownClearButtons: "[class*='clear'], [aria-label*='clear']",
|
|
1262
|
+
/** 多选标签移除按钮 */
|
|
1263
|
+
dropdownRemoveButtons: "[class*='remove'], [class*='close'], [aria-label*='close']",
|
|
1264
|
+
/** 空态指示器 */
|
|
1265
|
+
emptyStates: "[class*='empty'], [class*='no-data'], [role='status'], [class*='item-empty']",
|
|
1266
|
+
/** 表单项容器(宽松:BEM/-item 子串 + group + form 元素;label 反查主用) */
|
|
1267
|
+
formItemContainers: "[class*='form-item'], [class*='form__item'], [role='group'], form, .form-group",
|
|
1268
|
+
/**
|
|
1269
|
+
* 表单项容器(严格:带分隔符前缀,避免 platform-item 之类无关门类误命中;
|
|
1270
|
+
* elements 工具页内 labelOf 消费)
|
|
1271
|
+
*/
|
|
1272
|
+
formItemContainersStrict: "[class*='-form-item'], [class*='__form-item'], form, .form-group",
|
|
1273
|
+
/** 表单项宿主判定(目标是否处于表单语境;含 label 本身) */
|
|
1274
|
+
formItemHosts: "label, [class*='form-item'], [class*='form__item'], [class*='form-group']",
|
|
1275
|
+
/** 表单项 label(dom 原语 label 反查消费) */
|
|
1276
|
+
formItemLabels: "[class*='form-item-label'], [class*='form-item__label'], [class*='label'], label",
|
|
1277
|
+
/** 表单 label(简化集;elements 工具页内 labelOf 消费) */
|
|
1278
|
+
formLabelSimple: "[class*='label'], label"
|
|
1279
|
+
};
|
|
1280
|
+
/**
|
|
1281
|
+
* 注册新库(示例:naive-ui):
|
|
1282
|
+
* const naiveUi: UILibrarySpec = {
|
|
1283
|
+
* name: "naive-ui",
|
|
1284
|
+
* modalContainers: [".n-modal-container"],
|
|
1285
|
+
* drawerContainers: [".n-drawer-container"],
|
|
1286
|
+
* dropdownContainers: [".n-base-select-menu"],
|
|
1287
|
+
* dropdownOptions: [".n-base-select-option"],
|
|
1288
|
+
* };
|
|
1289
|
+
* 追加到 UI_LIBRARIES 即可,弹窗顶层仲裁与下拉浮层发现自动获得该库精确层支持;
|
|
1290
|
+
* 未注册的库仍由通用子串层兜底。配套要求:补一个该库真页面的 e2e
|
|
1291
|
+
* (playground 模式照抄 test:enterprise),防止想当然的类名污染仲裁。
|
|
1292
|
+
*/
|
|
1293
|
+
const UI_LIBRARIES = [{
|
|
1294
|
+
name: "antd",
|
|
1295
|
+
modalContainers: [".ant-modal", ".ant-modal-content"],
|
|
1296
|
+
drawerContainers: [".ant-drawer", ".ant-drawer-content-wrapper"],
|
|
1297
|
+
dropdownContainers: [".ant-select-dropdown"],
|
|
1298
|
+
dropdownOptions: [".ant-select-item-option"]
|
|
1299
|
+
}, {
|
|
1300
|
+
name: "element-plus",
|
|
1301
|
+
modalContainers: [".el-dialog"],
|
|
1302
|
+
drawerContainers: [".el-drawer"],
|
|
1303
|
+
dropdownContainers: [".el-select-dropdown"],
|
|
1304
|
+
dropdownOptions: [".el-select-dropdown__item"]
|
|
1305
|
+
}];
|
|
1306
|
+
const flatOf = (libs, key) => libs.flatMap((lib) => lib[key]);
|
|
1307
|
+
/** 弹窗/抽屉容器(库精确层原料,未加 display:none 预过滤)。 */
|
|
1308
|
+
function dialogContainersOf(libs = UI_LIBRARIES) {
|
|
1309
|
+
return [...flatOf(libs, "modalContainers"), ...flatOf(libs, "drawerContainers")];
|
|
1310
|
+
}
|
|
1311
|
+
/** 弹窗模糊层选择器串:子串词 + 子节点排除(:not 双命名)+ display:none 预过滤。 */
|
|
1312
|
+
function composeDialogFuzzy(words = GENERIC.dialogWords, excludedParts = GENERIC.dialogExcludedParts) {
|
|
1313
|
+
return words.map((word) => [`[class*='${word}']:not([style*='display: none'])`, ...excludedParts.map((part) => `:not([class*='${word}-${part}']):not([class*='${word}__${part}'])`)].join("")).join(", ");
|
|
1314
|
+
}
|
|
1315
|
+
/** 下拉浮层容器选择器列表(库精确层 + 通用子串层,均带 display:none 预过滤)。 */
|
|
1316
|
+
function dropdownFloatSelectors(libs = UI_LIBRARIES) {
|
|
1317
|
+
return [...flatOf(libs, "dropdownContainers"), ...GENERIC.dropdownFloatWords.map((w) => `[class*='${w}']`)].map((sel) => `${sel}:not([style*='display: none']):not([class*='hidden'])`);
|
|
1318
|
+
}
|
|
1319
|
+
/** 下拉选项行选择器列表(标准层 role=option 由消费方自带 + 库精确层 + 通用子串层)。 */
|
|
1320
|
+
function dropdownOptionRowsOf(libs = UI_LIBRARIES) {
|
|
1321
|
+
return [...flatOf(libs, "dropdownOptions").map((sel) => `${sel}:not([class*='empty'])`), GENERIC.dropdownOptionRows];
|
|
1322
|
+
}
|
|
1323
|
+
/** 预组合聚合常量(消费方直接 import)。 */
|
|
1324
|
+
const LIBRARY_DIALOG_CONTAINERS = dialogContainersOf();
|
|
1325
|
+
const LIBRARY_DROPDOWN_CONTAINERS = dropdownFloatSelectors();
|
|
1326
|
+
const LIBRARY_DROPDOWN_OPTIONS = dropdownOptionRowsOf();
|
|
1327
|
+
//#endregion
|
|
1006
1328
|
//#region lib/types/browser/tools/common.js
|
|
1007
|
-
/**
|
|
1008
|
-
const DROPDOWN_OPTION_SELECTOR =
|
|
1009
|
-
"[role='option']",
|
|
1010
|
-
"[role='menuitem']",
|
|
1011
|
-
"[class*='select'][class*='dropdown'] [class*='option']",
|
|
1012
|
-
"[class*='select'][class*='dropdown'] [class*='item']",
|
|
1013
|
-
"[role='listbox'] [role='option']"
|
|
1014
|
-
].join(", ");
|
|
1329
|
+
/** 组件库下拉框的可见选项枚举(ui-library 注册表单一来源,值与抽取前等价)。 */
|
|
1330
|
+
const DROPDOWN_OPTION_SELECTOR = GENERIC.dropdownOptionEnumeration;
|
|
1015
1331
|
/** 读取 PNG 文件头(IHDR)中的宽高,不解码像素(导出供测试)。 */
|
|
1016
1332
|
function pngSize(buf) {
|
|
1017
1333
|
if (buf.length < 24 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG file");
|
|
@@ -1146,10 +1462,216 @@ function workspaceFilePath(cwd, file) {
|
|
|
1146
1462
|
if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
|
|
1147
1463
|
return p;
|
|
1148
1464
|
}
|
|
1465
|
+
const AsyncFunctionConstructor = Object.getPrototypeOf(async function() {}).constructor;
|
|
1466
|
+
/** 编译校验(只编译不执行):语法合法返回 true。 */
|
|
1467
|
+
function compiles(body) {
|
|
1468
|
+
try {
|
|
1469
|
+
new AsyncFunctionConstructor(body);
|
|
1470
|
+
return true;
|
|
1471
|
+
} catch {
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
/** `/` 出现在这些字符(或关键字)之后按正则字面量处理,否则按除号。 */
|
|
1476
|
+
const REGEX_ALLOWED_AFTER_CHARS = /* @__PURE__ */ new Set("([{,;=:?&|!%^~*+<>-/");
|
|
1477
|
+
const REGEX_ALLOWED_AFTER_WORDS = /* @__PURE__ */ new Set([
|
|
1478
|
+
"return",
|
|
1479
|
+
"typeof",
|
|
1480
|
+
"instanceof",
|
|
1481
|
+
"in",
|
|
1482
|
+
"of",
|
|
1483
|
+
"new",
|
|
1484
|
+
"delete",
|
|
1485
|
+
"void",
|
|
1486
|
+
"throw",
|
|
1487
|
+
"case",
|
|
1488
|
+
"do",
|
|
1489
|
+
"else",
|
|
1490
|
+
"yield",
|
|
1491
|
+
"await"
|
|
1492
|
+
]);
|
|
1493
|
+
/**
|
|
1494
|
+
* 词法扫描(为 ② 服务,fail-safe):定位最后一个「顶层显式 ;」的结束位置,
|
|
1495
|
+
* 并检测顶层 return 关键字(有则说明调用方自己管理返回,不自动改写)。
|
|
1496
|
+
* 顶层 = 不在字符串/模板/注释/正则内,且 ()[]{} 合并深度为 0(含模板 ${} 插值内)。
|
|
1497
|
+
*/
|
|
1498
|
+
function scanTopLevel(script) {
|
|
1499
|
+
let lastSemicolonEnd = null;
|
|
1500
|
+
let topLevelReturn = false;
|
|
1501
|
+
let state = "code";
|
|
1502
|
+
let depth = 0;
|
|
1503
|
+
const tplDepths = [];
|
|
1504
|
+
let prevSig = "";
|
|
1505
|
+
let prevWord = "";
|
|
1506
|
+
const isWordChar = (c) => /[A-Za-z0-9_$]/.test(c);
|
|
1507
|
+
for (let i = 0; i < script.length; i++) {
|
|
1508
|
+
const ch = script[i];
|
|
1509
|
+
switch (state) {
|
|
1510
|
+
case "line":
|
|
1511
|
+
if (ch === "\n") state = "code";
|
|
1512
|
+
continue;
|
|
1513
|
+
case "block":
|
|
1514
|
+
if (ch === "*" && script[i + 1] === "/") {
|
|
1515
|
+
state = "code";
|
|
1516
|
+
i++;
|
|
1517
|
+
}
|
|
1518
|
+
continue;
|
|
1519
|
+
case "sq":
|
|
1520
|
+
case "dq":
|
|
1521
|
+
if (ch === "\\") i++;
|
|
1522
|
+
else if (state === "sq" && ch === "'" || state === "dq" && ch === "\"") state = "code";
|
|
1523
|
+
continue;
|
|
1524
|
+
case "tpl":
|
|
1525
|
+
if (ch === "\\") i++;
|
|
1526
|
+
else if (ch === "`") {
|
|
1527
|
+
state = "code";
|
|
1528
|
+
prevSig = "`";
|
|
1529
|
+
prevWord = "";
|
|
1530
|
+
} else if (ch === "$" && script[i + 1] === "{") {
|
|
1531
|
+
tplDepths.push(depth);
|
|
1532
|
+
state = "code";
|
|
1533
|
+
i++;
|
|
1534
|
+
}
|
|
1535
|
+
continue;
|
|
1536
|
+
case "regex":
|
|
1537
|
+
if (ch === "\\") i++;
|
|
1538
|
+
else if (ch === "[") state = "regexClass";
|
|
1539
|
+
else if (ch === "/") {
|
|
1540
|
+
state = "code";
|
|
1541
|
+
prevSig = "/";
|
|
1542
|
+
prevWord = "";
|
|
1543
|
+
}
|
|
1544
|
+
continue;
|
|
1545
|
+
case "regexClass":
|
|
1546
|
+
if (ch === "\\") i++;
|
|
1547
|
+
else if (ch === "]") state = "regex";
|
|
1548
|
+
continue;
|
|
1549
|
+
default:
|
|
1550
|
+
if (ch === "/" && script[i + 1] === "/") {
|
|
1551
|
+
state = "line";
|
|
1552
|
+
i++;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (ch === "/" && script[i + 1] === "*") {
|
|
1556
|
+
state = "block";
|
|
1557
|
+
i++;
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (ch === "/") {
|
|
1561
|
+
if (prevSig === "" || REGEX_ALLOWED_AFTER_CHARS.has(prevSig) || REGEX_ALLOWED_AFTER_WORDS.has(prevWord)) state = "regex";
|
|
1562
|
+
else {
|
|
1563
|
+
prevSig = "/";
|
|
1564
|
+
prevWord = "";
|
|
1565
|
+
}
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
if (ch === "'" || ch === "\"") {
|
|
1569
|
+
state = ch === "'" ? "sq" : "dq";
|
|
1570
|
+
prevSig = ch;
|
|
1571
|
+
prevWord = "";
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
if (ch === "`") {
|
|
1575
|
+
state = "tpl";
|
|
1576
|
+
prevSig = ch;
|
|
1577
|
+
prevWord = "";
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
1581
|
+
depth++;
|
|
1582
|
+
prevSig = ch;
|
|
1583
|
+
prevWord = "";
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
if (ch === ")" || ch === "]") {
|
|
1587
|
+
depth = Math.max(0, depth - 1);
|
|
1588
|
+
prevSig = ch;
|
|
1589
|
+
prevWord = "";
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
if (ch === "}") {
|
|
1593
|
+
if (tplDepths.length > 0 && depth === tplDepths[tplDepths.length - 1]) {
|
|
1594
|
+
tplDepths.pop();
|
|
1595
|
+
state = "tpl";
|
|
1596
|
+
} else depth = Math.max(0, depth - 1);
|
|
1597
|
+
prevSig = ch;
|
|
1598
|
+
prevWord = "";
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
if (ch === ";") {
|
|
1602
|
+
if (depth === 0 && tplDepths.length === 0) lastSemicolonEnd = i + 1;
|
|
1603
|
+
prevSig = ch;
|
|
1604
|
+
prevWord = "";
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (isWordChar(ch)) {
|
|
1608
|
+
let j = i;
|
|
1609
|
+
while (j < script.length && isWordChar(script[j])) j++;
|
|
1610
|
+
const word = script.slice(i, j);
|
|
1611
|
+
if (depth === 0 && tplDepths.length === 0 && word === "return" && prevSig !== "." && !isWordChar(prevSig)) topLevelReturn = true;
|
|
1612
|
+
prevWord = word;
|
|
1613
|
+
prevSig = script[j - 1];
|
|
1614
|
+
i = j - 1;
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
if (!/\s/.test(ch)) {
|
|
1618
|
+
prevSig = ch;
|
|
1619
|
+
prevWord = "";
|
|
1620
|
+
}
|
|
1621
|
+
continue;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
return {
|
|
1625
|
+
lastSemicolonEnd,
|
|
1626
|
+
topLevelReturn
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* 把 eval 脚本包装成可直接交给 page.evaluate 的代码串,并标明实际执行形态。
|
|
1631
|
+
* 语法非法时在 Node 侧抛出引擎原始错误(消息与页内 V8 同源,提前暴露)。
|
|
1632
|
+
*/
|
|
1633
|
+
function buildEvalScript(script) {
|
|
1634
|
+
if (compiles(`return (\n${script}\n)`)) return {
|
|
1635
|
+
code: `(async () => (\n${script}\n))()`,
|
|
1636
|
+
mode: "expression"
|
|
1637
|
+
};
|
|
1638
|
+
try {
|
|
1639
|
+
new AsyncFunctionConstructor(script);
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
throw new Error(`eval 脚本语法错误: ${err.message}`);
|
|
1642
|
+
}
|
|
1643
|
+
const { lastSemicolonEnd, topLevelReturn } = scanTopLevel(script);
|
|
1644
|
+
if (!topLevelReturn && lastSemicolonEnd !== null) {
|
|
1645
|
+
const head = script.slice(0, lastSemicolonEnd);
|
|
1646
|
+
const tail = script.slice(lastSemicolonEnd);
|
|
1647
|
+
if (tail.trim() !== "") {
|
|
1648
|
+
const candidate = `${head}\nreturn (\n${tail}\n);`;
|
|
1649
|
+
if (compiles(candidate)) return {
|
|
1650
|
+
code: `(async () => {\n${candidate}\n})()`,
|
|
1651
|
+
mode: "statements-auto-return"
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
return {
|
|
1656
|
+
code: `(async () => {\n${script}\n})()`,
|
|
1657
|
+
mode: "statements"
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1149
1660
|
/**
|
|
1150
1661
|
* 规则化抽取:fields 定义抽取结构,next 选择器驱动翻页(maxPages 上限),
|
|
1151
1662
|
* 行数超 maxRows 截断;format/file 控制序列化与落盘(json/csv/jsonl/markdown)。
|
|
1152
1663
|
*/
|
|
1664
|
+
async function assertExpectedUrl(page, expectUrl, action) {
|
|
1665
|
+
if (typeof expectUrl !== "string" || expectUrl === "") return;
|
|
1666
|
+
const current = page.url();
|
|
1667
|
+
let hit;
|
|
1668
|
+
try {
|
|
1669
|
+
hit = new RegExp(expectUrl).test(current);
|
|
1670
|
+
} catch {
|
|
1671
|
+
hit = current.includes(expectUrl);
|
|
1672
|
+
}
|
|
1673
|
+
if (!hit) throw new Error(`${action} 前置校验失败: expectUrl "${expectUrl}" 与当前页面不符(当前: ${current})——页面可能被切换/回退,请先 browser_snapshot 确认后再操作`);
|
|
1674
|
+
}
|
|
1153
1675
|
//#endregion
|
|
1154
1676
|
//#region lib/types/browser/primitives/dom.js
|
|
1155
1677
|
/**
|
|
@@ -1167,9 +1689,9 @@ function extractElementLabel(el) {
|
|
|
1167
1689
|
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)).filter((n) => !!n).map((n) => (n.innerText || n.textContent || "").trim()).filter(Boolean).join(" ");
|
|
1168
1690
|
if (text) return text;
|
|
1169
1691
|
}
|
|
1170
|
-
const formItem = el.closest(
|
|
1692
|
+
const formItem = el.closest(GENERIC.formItemContainers);
|
|
1171
1693
|
if (formItem) {
|
|
1172
|
-
const labelEl = formItem.querySelector(
|
|
1694
|
+
const labelEl = formItem.querySelector(GENERIC.formItemLabels);
|
|
1173
1695
|
if (labelEl) {
|
|
1174
1696
|
const txt = (labelEl.innerText || labelEl.textContent || "").trim();
|
|
1175
1697
|
if (txt) return txt;
|
|
@@ -1364,29 +1886,63 @@ async function endChangeWatch(page, options = {}) {
|
|
|
1364
1886
|
}
|
|
1365
1887
|
//#endregion
|
|
1366
1888
|
//#region lib/types/browser/primitives/dialog.js
|
|
1889
|
+
/** 标准层:WAI-ARIA 语义,任何库与原生实现通用。 */
|
|
1890
|
+
const ARIA_DIALOG_SELECTORS = [
|
|
1891
|
+
"[role='dialog']",
|
|
1892
|
+
"[role='alertdialog']",
|
|
1893
|
+
"[aria-modal='true']",
|
|
1894
|
+
"dialog[open]"
|
|
1895
|
+
];
|
|
1896
|
+
/** display:none 内联样式预过滤(动画关闭中的弹窗/keep-alive 残留)。 */
|
|
1897
|
+
const hiddenGuard = (sel) => `${sel}:not([style*='display: none'])`;
|
|
1898
|
+
/** 精确层 = 标准层 + 库精确层(ui-library 注册表单一来源,新增 UI 库 = 追加 spec)。 */
|
|
1899
|
+
const DIALOG_PRECISE_SELECTORS = [...ARIA_DIALOG_SELECTORS, ...LIBRARY_DIALOG_CONTAINERS].map(hiddenGuard).join(", ");
|
|
1900
|
+
/** 通用模糊层(未注册库兜底):子串词 + :not() 排除子节点。 */
|
|
1901
|
+
const DIALOG_FUZZY_SELECTORS = composeDialogFuzzy();
|
|
1367
1902
|
/**
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1903
|
+
* 历史并集 = 精确层 + 模糊层。semantic.ts 弹窗上下文检测与 getActiveDialog 的
|
|
1904
|
+
* domOrder 兜底消费(行为与抽取前的并集等价)。
|
|
1370
1905
|
*/
|
|
1371
|
-
const ACTIVE_DIALOG_SELECTORS = [
|
|
1372
|
-
"[role='dialog']:not([style*='display: none'])",
|
|
1373
|
-
"[role='alertdialog']:not([style*='display: none'])",
|
|
1374
|
-
"[aria-modal='true']:not([style*='display: none'])",
|
|
1375
|
-
"dialog[open]",
|
|
1376
|
-
".ant-modal:not([style*='display: none'])",
|
|
1377
|
-
".el-dialog:not([style*='display: none'])",
|
|
1378
|
-
".ant-drawer:not([style*='display: none'])",
|
|
1379
|
-
".el-drawer:not([style*='display: none'])",
|
|
1380
|
-
"[class*='modal']:not([style*='display: none'])",
|
|
1381
|
-
"[class*='drawer']:not([style*='display: none'])",
|
|
1382
|
-
"[class*='dialog']:not([style*='display: none'])"
|
|
1383
|
-
].join(", ");
|
|
1906
|
+
const ACTIVE_DIALOG_SELECTORS = [DIALOG_PRECISE_SELECTORS, DIALOG_FUZZY_SELECTORS].join(", ");
|
|
1384
1907
|
/**
|
|
1385
|
-
*
|
|
1908
|
+
* 获取当前页面上处于活动状态的最顶层弹窗容器;无弹窗返回 null。
|
|
1909
|
+
* ① 快速通道——并集零命中(绝大多数无弹窗的工具调用)直接返回 null,与旧行为
|
|
1910
|
+
* 「空 locator + 下游 count 0」等价,零额外 evaluate 往返;
|
|
1911
|
+
* ② 有弹窗时走分层选择器 + 页内 z-index/可见性仲裁(walker pickTopDialog,
|
|
1912
|
+
* 页内逻辑单一来源),单次 round-trip;
|
|
1913
|
+
* ③ evaluate 失败(极端 CSP/销毁竞态)或复核计数为 0 时回退文档序 last()
|
|
1914
|
+
* 旧行为并标 via: "domOrder",不阻断主流程。
|
|
1386
1915
|
*/
|
|
1387
|
-
function getActiveDialog(page) {
|
|
1916
|
+
async function getActiveDialog(page) {
|
|
1917
|
+
try {
|
|
1918
|
+
const unionLoc = page.locator(ACTIVE_DIALOG_SELECTORS);
|
|
1919
|
+
if (typeof unionLoc.count === "function") {
|
|
1920
|
+
if (await withTimeout(unionLoc.count(), 800, "弹窗快速探测").catch(() => 0) === 0) return null;
|
|
1921
|
+
}
|
|
1922
|
+
} catch {}
|
|
1923
|
+
try {
|
|
1924
|
+
const pick = await withTimeout(page.evaluate(pickTopDialog, {
|
|
1925
|
+
precise: DIALOG_PRECISE_SELECTORS,
|
|
1926
|
+
fuzzy: DIALOG_FUZZY_SELECTORS
|
|
1927
|
+
}), 1500, "活动弹窗仲裁");
|
|
1928
|
+
if (pick !== null && typeof pick === "object" && typeof pick.selector === "string" && pick.selector !== "") {
|
|
1929
|
+
const hit = pick;
|
|
1930
|
+
const loc = page.locator(hit.selector);
|
|
1931
|
+
if (typeof loc.count === "function") {
|
|
1932
|
+
if (await withTimeout(loc.count(), 800, "活动弹窗复核").catch(() => 0) > 0) return {
|
|
1933
|
+
locator: typeof loc.first === "function" ? loc.first() : loc,
|
|
1934
|
+
via: "zIndex",
|
|
1935
|
+
z: hit.z,
|
|
1936
|
+
tier: hit.tier
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
} catch {}
|
|
1388
1941
|
const dialogLoc = page.locator(ACTIVE_DIALOG_SELECTORS);
|
|
1389
|
-
return
|
|
1942
|
+
return {
|
|
1943
|
+
locator: typeof dialogLoc.last === "function" ? dialogLoc.last() : dialogLoc.first(),
|
|
1944
|
+
via: "domOrder"
|
|
1945
|
+
};
|
|
1390
1946
|
}
|
|
1391
1947
|
//#endregion
|
|
1392
1948
|
//#region lib/types/browser/primitives/select.js
|
|
@@ -1405,14 +1961,14 @@ async function executeUniversalSelect(ctx) {
|
|
|
1405
1961
|
throw new Error(`原生下拉无选项 "${want}"; 可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
|
|
1406
1962
|
}
|
|
1407
1963
|
let resolvedTarget = target;
|
|
1408
|
-
if (await withTimeout(target.evaluate((el) => {
|
|
1964
|
+
if (await withTimeout(target.evaluate((el, hosts) => {
|
|
1409
1965
|
if (el.tagName.toLowerCase() === "label") return true;
|
|
1410
1966
|
const cls = typeof el.className === "string" ? el.className : "";
|
|
1411
1967
|
if (/(?:form-item|form__item|form-group)/i.test(cls)) return true;
|
|
1412
|
-
if (el.closest(
|
|
1968
|
+
if (el.closest(hosts)) return !el.matches("[role='combobox'], select, [class*='select-selector']");
|
|
1413
1969
|
if (!el.matches("[role='combobox'], select, [class*='select'], [class*='selector']")) return true;
|
|
1414
1970
|
return false;
|
|
1415
|
-
}), 1500, "探测 label 或非标文本容器").catch(() => false)) {
|
|
1971
|
+
}, GENERIC.formItemHosts), 1500, "探测 label 或非标文本容器").catch(() => false)) {
|
|
1416
1972
|
const descendantSelect = target.locator("xpath=.//*[@role='combobox' or self::select or contains(@class, 'select-selector') or contains(@class, 'select')][not(self::label)]").first();
|
|
1417
1973
|
if (await descendantSelect.count().catch(() => 0) > 0) resolvedTarget = descendantSelect;
|
|
1418
1974
|
else {
|
|
@@ -1437,15 +1993,15 @@ async function executeUniversalSelect(ctx) {
|
|
|
1437
1993
|
const hostLocator = resolvedTarget.locator("xpath=ancestor-or-self::*[@role='combobox' or contains(@class, 'select') or contains(@class, 'selector')][last()]");
|
|
1438
1994
|
const actualTarget = await hostLocator.count().catch(() => 0) > 0 ? hostLocator.first() : resolvedTarget;
|
|
1439
1995
|
if (clear) try {
|
|
1440
|
-
const clearBtn = actualTarget.locator(
|
|
1996
|
+
const clearBtn = actualTarget.locator(GENERIC.dropdownClearButtons).first();
|
|
1441
1997
|
if (await clearBtn.count().catch(() => 0) > 0 && await clearBtn.isVisible().catch(() => false)) await clearBtn.click({ timeout: 1e3 }).catch(() => {});
|
|
1442
1998
|
else {
|
|
1443
|
-
const removeBtns = actualTarget.locator(
|
|
1999
|
+
const removeBtns = actualTarget.locator(GENERIC.dropdownRemoveButtons);
|
|
1444
2000
|
const removeCount = await removeBtns.count().catch(() => 0);
|
|
1445
2001
|
for (let i = 0; i < removeCount; i++) await removeBtns.nth(0).click({ timeout: 500 }).catch(() => {});
|
|
1446
2002
|
}
|
|
1447
2003
|
} catch {}
|
|
1448
|
-
|
|
2004
|
+
const probeExpanded = () => withTimeout(actualTarget.evaluate((el) => {
|
|
1449
2005
|
if (el.getAttribute("aria-expanded") === "true") return true;
|
|
1450
2006
|
const host = el.closest("[role='combobox'], [class*='select']") || el;
|
|
1451
2007
|
if (host && host.getAttribute("aria-expanded") === "true") return true;
|
|
@@ -1458,8 +2014,11 @@ async function executeUniversalSelect(ctx) {
|
|
|
1458
2014
|
if (dd && dd.offsetParent !== null) return true;
|
|
1459
2015
|
}
|
|
1460
2016
|
return false;
|
|
1461
|
-
}), 2e3, "检查下拉展开状态").catch(() => false)
|
|
1462
|
-
|
|
2017
|
+
}), 2e3, "检查下拉展开状态").catch(() => false);
|
|
2018
|
+
const isExpanded = await probeExpanded();
|
|
2019
|
+
let expandSignalMissed = false;
|
|
2020
|
+
if (!isExpanded) {
|
|
2021
|
+
const triggerChild = actualTarget.locator(GENERIC.dropdownTriggerParts).first();
|
|
1463
2022
|
let clicked = false;
|
|
1464
2023
|
if (await triggerChild.count().catch(() => 0) > 0) {
|
|
1465
2024
|
await triggerChild.click({ timeout: 2e3 }).catch(async () => {
|
|
@@ -1477,7 +2036,9 @@ async function executeUniversalSelect(ctx) {
|
|
|
1477
2036
|
});
|
|
1478
2037
|
});
|
|
1479
2038
|
await page.waitForTimeout(120);
|
|
2039
|
+
expandSignalMissed = !await probeExpanded();
|
|
1480
2040
|
}
|
|
2041
|
+
const optionWaitMs = expandSignalMissed ? 1500 : Math.min(timeout, 5e3);
|
|
1481
2042
|
const controlsId = await withTimeout(actualTarget.evaluate((el) => {
|
|
1482
2043
|
const direct = el.getAttribute("aria-controls") || el.getAttribute("aria-owns");
|
|
1483
2044
|
if (direct) return direct;
|
|
@@ -1485,8 +2046,8 @@ async function executeUniversalSelect(ctx) {
|
|
|
1485
2046
|
const inner = host?.querySelector("input[role='combobox'], [aria-controls], [aria-owns]");
|
|
1486
2047
|
return inner?.getAttribute("aria-controls") || inner?.getAttribute("aria-owns") || host?.getAttribute("aria-controls") || "";
|
|
1487
2048
|
}), 2e3, "解析下拉关联 controlsId").catch(() => "");
|
|
1488
|
-
const optionSelector = "[role='option']:not([style*='height: 0px']):not([class*='empty']),
|
|
1489
|
-
const dropdownSelector = "[role='listbox']:not([style*='display: none']),
|
|
2049
|
+
const optionSelector = ["[role='option']:not([style*='height: 0px']):not([class*='empty'])", ...LIBRARY_DROPDOWN_OPTIONS].join(", ");
|
|
2050
|
+
const dropdownSelector = ["[role='listbox']:not([style*='display: none'])", ...LIBRARY_DROPDOWN_CONTAINERS].join(", ");
|
|
1490
2051
|
let options;
|
|
1491
2052
|
let targetDropdown = null;
|
|
1492
2053
|
if (controlsId) {
|
|
@@ -1504,7 +2065,7 @@ async function executeUniversalSelect(ctx) {
|
|
|
1504
2065
|
}
|
|
1505
2066
|
await options.first().waitFor({
|
|
1506
2067
|
state: "visible",
|
|
1507
|
-
timeout:
|
|
2068
|
+
timeout: optionWaitMs
|
|
1508
2069
|
}).catch(() => {});
|
|
1509
2070
|
const visibleTexts = (await options.evaluateAll((els) => els.map((el) => {
|
|
1510
2071
|
return {
|
|
@@ -1514,10 +2075,12 @@ async function executeUniversalSelect(ctx) {
|
|
|
1514
2075
|
};
|
|
1515
2076
|
})).catch(() => [])).map((e) => e.text || e.title || e.aria).filter(Boolean);
|
|
1516
2077
|
if (visibleTexts.length === 0) {
|
|
1517
|
-
const
|
|
2078
|
+
const emptySelector = GENERIC.emptyStates;
|
|
2079
|
+
const isEmpty = targetDropdown !== null ? await targetDropdown.locator(emptySelector).first().isVisible().catch(() => false) : false;
|
|
1518
2080
|
await actualTarget.click({ timeout: 1e3 }).catch(() => {});
|
|
1519
|
-
|
|
1520
|
-
throw new Error(
|
|
2081
|
+
const attribution = expandSignalMissed ? ";触发器点击疑似未生效(展开信号缺失,可能被透明遮罩拦截,建议 browser_snapshot 核实或配合 click effect 验证)" : "";
|
|
2082
|
+
if (isEmpty) throw new Error(`下拉浮层当前无选项(数据为空态/无可选数据): ${label}${attribution}`);
|
|
2083
|
+
throw new Error(`点击 ${label} 后未出现下拉选项(选项列表为空态)${attribution}`);
|
|
1521
2084
|
}
|
|
1522
2085
|
const wantStripped = want.replace(/\s+/g, "");
|
|
1523
2086
|
let index = visibleTexts.findIndex((t) => t === want);
|
|
@@ -1663,7 +2226,7 @@ async function fillFormFieldHelper(page, field, scope) {
|
|
|
1663
2226
|
const root = scope ? page.locator(scope) : page;
|
|
1664
2227
|
const { key, value } = field;
|
|
1665
2228
|
try {
|
|
1666
|
-
const formItems = root.locator(
|
|
2229
|
+
const formItems = root.locator(GENERIC.formItemContainers);
|
|
1667
2230
|
const count = await formItems.count().catch(() => 0);
|
|
1668
2231
|
for (let i = 0; i < count; i++) {
|
|
1669
2232
|
const item = formItems.nth(i);
|
|
@@ -1725,6 +2288,45 @@ async function fillFormFieldHelper(page, field, scope) {
|
|
|
1725
2288
|
};
|
|
1726
2289
|
}
|
|
1727
2290
|
//#endregion
|
|
2291
|
+
//#region lib/types/browser/primitives/drag.js
|
|
2292
|
+
/**
|
|
2293
|
+
* 核心:在两个已解析的 Locator 之间执行拖拽。
|
|
2294
|
+
* 优先 Playwright dragTo(覆盖 HTML5 DnD 与多数组件库);
|
|
2295
|
+
* 失败或调用方指定 preferMouse(如跨 Frame,dragTo 的已知限制)时
|
|
2296
|
+
* 降级为物理鼠标序列。返回实际使用的通道。失败抛错(带可定位的诊断信息)。
|
|
2297
|
+
*/
|
|
2298
|
+
async function dragAndDropLocators(page, source, target, opts = {}) {
|
|
2299
|
+
const timeoutMs = opts.timeoutMs ?? 3e3;
|
|
2300
|
+
if (!opts.preferMouse) try {
|
|
2301
|
+
if (typeof source.dragTo === "function") {
|
|
2302
|
+
await source.dragTo(target, { timeout: timeoutMs });
|
|
2303
|
+
return "dragTo";
|
|
2304
|
+
}
|
|
2305
|
+
} catch {}
|
|
2306
|
+
let srcErr = null;
|
|
2307
|
+
const srcBox = await source.boundingBox({ timeout: timeoutMs }).catch((e) => {
|
|
2308
|
+
srcErr = e;
|
|
2309
|
+
return null;
|
|
2310
|
+
});
|
|
2311
|
+
if (!srcBox) throw new Error(`drag 失败: 源元素不可见或未命中(等待 ${timeoutMs}ms)${srcErr instanceof Error ? `: ${srcErr.message}` : ""}`);
|
|
2312
|
+
let tgtErr = null;
|
|
2313
|
+
const tgtBox = await target.boundingBox({ timeout: timeoutMs }).catch((e) => {
|
|
2314
|
+
tgtErr = e;
|
|
2315
|
+
return null;
|
|
2316
|
+
});
|
|
2317
|
+
if (!tgtBox) throw new Error(`drag 失败: 目标元素不可见或未命中(等待 ${timeoutMs}ms)${tgtErr instanceof Error ? `: ${tgtErr.message}` : ""}`);
|
|
2318
|
+
const viewport = typeof page.viewportSize === "function" ? page.viewportSize() : null;
|
|
2319
|
+
const outsideViewport = viewport !== null && (srcBox.x + srcBox.width < 0 || srcBox.x > viewport.width || tgtBox.x + tgtBox.width < 0 || tgtBox.x > viewport.width || srcBox.y + srcBox.height < 0 || srcBox.y > viewport.height || tgtBox.y + tgtBox.height < 0 || tgtBox.y > viewport.height);
|
|
2320
|
+
if (outsideViewport && typeof target.scrollIntoViewIfNeeded === "function") await target.scrollIntoViewIfNeeded({ timeout: timeoutMs }).catch(() => {});
|
|
2321
|
+
const sx = (outsideViewport ? await source.boundingBox({ timeout: timeoutMs }).catch(() => null) : null) ?? srcBox;
|
|
2322
|
+
const tx = (outsideViewport ? await target.boundingBox({ timeout: timeoutMs }).catch(() => null) : null) ?? tgtBox;
|
|
2323
|
+
await page.mouse.move(sx.x + sx.width / 2, sx.y + sx.height / 2);
|
|
2324
|
+
await page.mouse.down();
|
|
2325
|
+
await page.mouse.move(tx.x + tx.width / 2, tx.y + tx.height / 2, { steps: 8 });
|
|
2326
|
+
await page.mouse.up();
|
|
2327
|
+
return "mouse";
|
|
2328
|
+
}
|
|
2329
|
+
//#endregion
|
|
1728
2330
|
//#region lib/types/browser/session/semantic.js
|
|
1729
2331
|
/**
|
|
1730
2332
|
* 页内语义扫描:按 query 打分收集可交互元素(弹窗内优先、可见优先、高分优先)。
|
|
@@ -2083,6 +2685,37 @@ const failureContextProbe = injectWalker(() => {
|
|
|
2083
2685
|
hasShadow
|
|
2084
2686
|
};
|
|
2085
2687
|
});
|
|
2688
|
+
/**
|
|
2689
|
+
* selector 零命中时的相似属性探针(best-effort):提取首个属性片段(或 #id)
|
|
2690
|
+
* 做包含匹配计数——直接暴露「锚点属性全失效」与「组合选择器其他部分写错」两种病因。
|
|
2691
|
+
* 纯提示用途,失败/无属性片段返回空串。
|
|
2692
|
+
*/
|
|
2693
|
+
const similarAttrProbe = injectWalker((rawSelector) => {
|
|
2694
|
+
let attr = "";
|
|
2695
|
+
let val = "";
|
|
2696
|
+
const m = rawSelector.match(/\[([\w-]+)(?:\^|\$|\*|~)?=['"]([^'"]+)['"]\]/);
|
|
2697
|
+
if (m !== null) {
|
|
2698
|
+
attr = m[1] ?? "";
|
|
2699
|
+
val = m[2] ?? "";
|
|
2700
|
+
} else {
|
|
2701
|
+
const idm = rawSelector.match(/^#([\w-]+)/);
|
|
2702
|
+
if (idm !== null) {
|
|
2703
|
+
attr = "id";
|
|
2704
|
+
val = idm[1] ?? "";
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
if (attr === "" || val === "") return "";
|
|
2708
|
+
try {
|
|
2709
|
+
const safe = val.replace(/["\\]/g, "");
|
|
2710
|
+
if (safe === "") return "";
|
|
2711
|
+
const hits = Array.from(document.querySelectorAll(`[${attr}*="${safe}"]`)).slice(0, 50);
|
|
2712
|
+
if (hits.length === 0) return `页面不存在任何 ${attr} 含 "${safe}" 的元素(锚点属性可能已失效或页面已切换)`;
|
|
2713
|
+
const samples = hits.slice(0, 3).map((el) => el.getAttribute(attr) || "").filter((s) => s !== "").join(" | ").slice(0, 120);
|
|
2714
|
+
return `页面存在 ${hits.length} 个 ${attr} 含 "${safe}" 的元素(组合选择器的其他部分可能写错)${samples !== "" ? `,如: ${samples}` : ""}`;
|
|
2715
|
+
} catch {
|
|
2716
|
+
return "";
|
|
2717
|
+
}
|
|
2718
|
+
});
|
|
2086
2719
|
/** ref 坐标兜底的名称相似判定:去空白互相包含,或 LCS 占比 ≥ 0.6。 */
|
|
2087
2720
|
function nameSimilar(a, b) {
|
|
2088
2721
|
if (a === "" || b === "") return true;
|
|
@@ -2164,9 +2797,9 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2164
2797
|
};
|
|
2165
2798
|
}
|
|
2166
2799
|
if (!isIFrame) try {
|
|
2167
|
-
const
|
|
2168
|
-
if (
|
|
2169
|
-
const inDialog = entry.name !== "" ?
|
|
2800
|
+
const dlg = (await getActiveDialog(page))?.locator;
|
|
2801
|
+
if (dlg && typeof dlg.getByRole === "function") {
|
|
2802
|
+
const inDialog = entry.name !== "" ? dlg.getByRole(entry.role, { name: buildFlexibleTextPattern(entry.name) }).first() : dlg.getByRole(entry.role).first();
|
|
2170
2803
|
if (await withTimeout(inDialog.count(), 1e3, "弹窗内 role 查找").catch(() => 0) > 0) return {
|
|
2171
2804
|
locator: inDialog,
|
|
2172
2805
|
container,
|
|
@@ -2288,10 +2921,29 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2288
2921
|
}
|
|
2289
2922
|
const what = args.what;
|
|
2290
2923
|
if (typeof what === "string" && what.trim() !== "" && args.selector === void 0 && args.ref === void 0 && args.mark === void 0 && rowPattern === void 0) {
|
|
2924
|
+
const navSeqBefore = pageNavSeq(page);
|
|
2925
|
+
let urlBefore = null;
|
|
2926
|
+
try {
|
|
2927
|
+
urlBefore = page.url();
|
|
2928
|
+
} catch {}
|
|
2291
2929
|
const res = await resolveSemanticTarget(page, what, {
|
|
2292
2930
|
scope: typeof args.scope === "string" && args.scope !== "" ? args.scope : void 0,
|
|
2293
2931
|
preferredRoles: opts.preferredRoles
|
|
2294
2932
|
});
|
|
2933
|
+
let urlAfter = urlBefore;
|
|
2934
|
+
try {
|
|
2935
|
+
urlAfter = page.url();
|
|
2936
|
+
} catch {}
|
|
2937
|
+
if (pageNavSeq(page) !== navSeqBefore || urlAfter !== urlBefore) {
|
|
2938
|
+
const raceErr = /* @__PURE__ */ new Error(`页面在语义定位过程中发生变化(${urlBefore} → ${page.url()}),本次扫描结果已作废;请重试或重新 browser_snapshot`);
|
|
2939
|
+
raceErr.locationFailure = {
|
|
2940
|
+
phase: "navRace",
|
|
2941
|
+
candidates: 0,
|
|
2942
|
+
dialogActive: false,
|
|
2943
|
+
hasShadow: false
|
|
2944
|
+
};
|
|
2945
|
+
throw raceErr;
|
|
2946
|
+
}
|
|
2295
2947
|
if (!("candidates" in res)) return res;
|
|
2296
2948
|
for (const role of [
|
|
2297
2949
|
"button",
|
|
@@ -2348,6 +3000,14 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2348
3000
|
...ctx
|
|
2349
3001
|
};
|
|
2350
3002
|
} catch {}
|
|
3003
|
+
const navDelta = navSinceStable(page);
|
|
3004
|
+
if (navDelta >= 1) {
|
|
3005
|
+
let route = "";
|
|
3006
|
+
try {
|
|
3007
|
+
route = page.url().slice(0, 80);
|
|
3008
|
+
} catch {}
|
|
3009
|
+
err.message = `${err.message};页面自上次成功操作后已发生 ${navDelta} 次导航,what 目标可能在另一页面${route !== "" ? `(当前: ${route})` : ""}`;
|
|
3010
|
+
}
|
|
2351
3011
|
throw err;
|
|
2352
3012
|
}
|
|
2353
3013
|
if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一 (或指定 row 与 action 进行表格行级定界,或用 what 语义定位)");
|
|
@@ -2365,9 +3025,9 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2365
3025
|
}
|
|
2366
3026
|
}
|
|
2367
3027
|
try {
|
|
2368
|
-
const
|
|
2369
|
-
if (
|
|
2370
|
-
const inDialog =
|
|
3028
|
+
const dlg = (await getActiveDialog(page))?.locator;
|
|
3029
|
+
if (dlg && typeof dlg.locator === "function") {
|
|
3030
|
+
const inDialog = dlg.locator(rawSelector);
|
|
2371
3031
|
if (typeof inDialog.count === "function") {
|
|
2372
3032
|
const inDialogCount = await withTimeout(inDialog.count(), 1e3, "弹窗内 selector 探测").catch(() => 0);
|
|
2373
3033
|
if (inDialogCount > 0) {
|
|
@@ -2388,7 +3048,43 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2388
3048
|
let visibleCount;
|
|
2389
3049
|
let target = typeof baseLocator.first === "function" ? baseLocator.first() : baseLocator;
|
|
2390
3050
|
if (typeof baseLocator.count === "function") {
|
|
2391
|
-
|
|
3051
|
+
try {
|
|
3052
|
+
totalCount = await withTimeout(baseLocator.count(), 2e3, "selector 计数");
|
|
3053
|
+
} catch {
|
|
3054
|
+
const err = /* @__PURE__ */ new Error(`selector 计数超时: ${rawSelector}(页面可能正忙或 DOM 过大,可重试;若持续超时建议 browser_snapshot 核实页面状态)`);
|
|
3055
|
+
err.locationFailure = {
|
|
3056
|
+
phase: "countTimeout",
|
|
3057
|
+
candidates: 0,
|
|
3058
|
+
dialogActive: false,
|
|
3059
|
+
hasShadow: false
|
|
3060
|
+
};
|
|
3061
|
+
throw err;
|
|
3062
|
+
}
|
|
3063
|
+
if (totalCount === 0) {
|
|
3064
|
+
const zeroMatchErr = /* @__PURE__ */ new Error(`selector 未命中: ${rawSelector}(元素未渲染/选择器写错/页面已切换;可用 browser_elements 探查表单结构、browser_wait_for 等元素出现,或 browser_snapshot 取 ref)`);
|
|
3065
|
+
zeroMatchErr.locationFailure = {
|
|
3066
|
+
phase: "zeroMatch",
|
|
3067
|
+
candidates: 0,
|
|
3068
|
+
dialogActive: false,
|
|
3069
|
+
hasShadow: false
|
|
3070
|
+
};
|
|
3071
|
+
try {
|
|
3072
|
+
const ctx = await withTimeout(page.evaluate(failureContextProbe, void 0), 1500, "失败特征探测");
|
|
3073
|
+
if (ctx !== null && typeof ctx === "object") zeroMatchErr.locationFailure = {
|
|
3074
|
+
phase: "zeroMatch",
|
|
3075
|
+
candidates: 0,
|
|
3076
|
+
...ctx
|
|
3077
|
+
};
|
|
3078
|
+
} catch {}
|
|
3079
|
+
try {
|
|
3080
|
+
const hint = await withTimeout(page.evaluate(similarAttrProbe, rawSelector), 1500, "相似属性探测");
|
|
3081
|
+
if (typeof hint === "string" && hint !== "") {
|
|
3082
|
+
zeroMatchErr.similarHint = hint;
|
|
3083
|
+
zeroMatchErr.message = `${zeroMatchErr.message};${hint}`;
|
|
3084
|
+
}
|
|
3085
|
+
} catch {}
|
|
3086
|
+
throw zeroMatchErr;
|
|
3087
|
+
}
|
|
2392
3088
|
if (totalCount > 0 && typeof baseLocator.locator === "function") {
|
|
2393
3089
|
const visibleLocator = baseLocator.locator("visible=true");
|
|
2394
3090
|
if (typeof visibleLocator.count === "function") {
|
|
@@ -2803,6 +3499,8 @@ const DOM_COMPAT_SCRIPT = `
|
|
|
2803
3499
|
};
|
|
2804
3500
|
})();
|
|
2805
3501
|
`;
|
|
3502
|
+
/** 最近一次工具触碰时间戳(getPage 打点):外部导航判定基准。 */
|
|
3503
|
+
let lastToolTouchAt = 0;
|
|
2806
3504
|
function attachPage(page, config) {
|
|
2807
3505
|
const timeoutMs = config.timeoutMs ?? 3e4;
|
|
2808
3506
|
page.setDefaultTimeout(timeoutMs);
|
|
@@ -2826,6 +3524,7 @@ function attachPage(page, config) {
|
|
|
2826
3524
|
const url = frame.url();
|
|
2827
3525
|
if (!url || url === "about:blank") return;
|
|
2828
3526
|
recordNav(profile, url);
|
|
3527
|
+
if (Date.now() - lastToolTouchAt > 1e4) noteExternalNav(page, url);
|
|
2829
3528
|
});
|
|
2830
3529
|
page.on("load", () => {
|
|
2831
3530
|
const list = historyOf(profile);
|
|
@@ -3051,6 +3750,7 @@ async function newPage(config) {
|
|
|
3051
3750
|
}
|
|
3052
3751
|
/** 返回当前活动页面(没有则新建),并保证浏览器已启动。 */
|
|
3053
3752
|
async function getPage(config) {
|
|
3753
|
+
lastToolTouchAt = Date.now();
|
|
3054
3754
|
const state = stateOf();
|
|
3055
3755
|
if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
|
|
3056
3756
|
pruneClosedPages(state);
|
|
@@ -3114,6 +3814,7 @@ async function closeBrowser() {
|
|
|
3114
3814
|
}
|
|
3115
3815
|
/** Best-effort current page identity, safe when nothing is open.title 有界防页面忙时挂起。 */
|
|
3116
3816
|
async function pageIdentity(page) {
|
|
3817
|
+
markStableNav(page);
|
|
3117
3818
|
try {
|
|
3118
3819
|
const title = await new Promise((resolve, reject) => {
|
|
3119
3820
|
const t = setTimeout(() => reject(/* @__PURE__ */ new Error("title timeout")), 2e3);
|
|
@@ -3718,15 +4419,20 @@ async function snapshotTool(config, args) {
|
|
|
3718
4419
|
const isDialogScope = /modal|dialog|drawer/i.test(scope);
|
|
3719
4420
|
let hint = isDialogScope ? "。目标容器可能已消失(弹窗/抽屉已关闭、已提交或页面已导航)" : "";
|
|
3720
4421
|
if (isDialogScope && typeof page.locator === "function") try {
|
|
3721
|
-
const activeDialogLoc = getActiveDialog(page);
|
|
3722
|
-
|
|
4422
|
+
const activeDialogLoc = (await getActiveDialog(page))?.locator;
|
|
4423
|
+
const count = activeDialogLoc !== void 0 && typeof activeDialogLoc.count === "function" ? await withTimeout(activeDialogLoc.count(), 1e3, "活动弹窗探测").catch(() => 0) : 0;
|
|
4424
|
+
if (activeDialogLoc !== void 0 && count > 0 && typeof activeDialogLoc.first === "function") {
|
|
3723
4425
|
const alternate = await withTimeout(activeDialogLoc.first().evaluate((el) => {
|
|
3724
|
-
|
|
4426
|
+
const out = [];
|
|
4427
|
+
const role = el.getAttribute("role");
|
|
4428
|
+
if (role === "dialog" || role === "alertdialog") out.push(`[role='${role}']`);
|
|
3725
4429
|
const cls = el.className;
|
|
3726
4430
|
if (typeof cls === "string" && cls.trim()) {
|
|
3727
|
-
const
|
|
3728
|
-
|
|
4431
|
+
const tokens = cls.trim().split(/\s+/);
|
|
4432
|
+
const relevant = tokens.find((c) => /modal|dialog|drawer/i.test(c)) || tokens[0];
|
|
4433
|
+
out.push(`.${relevant}`);
|
|
3729
4434
|
}
|
|
4435
|
+
if (out.length > 0) return out.join("");
|
|
3730
4436
|
return el.tagName.toLowerCase();
|
|
3731
4437
|
}), 1e3, "解析活动弹窗标识").catch(() => null);
|
|
3732
4438
|
if (alternate) hint += ` (检测到当前页面存在「${alternate}」容器,可能混淆了弹窗与抽屉类型)`;
|
|
@@ -4046,7 +4752,7 @@ async function somFallbackShot(config, exec) {
|
|
|
4046
4752
|
async function elementsTool(config, args) {
|
|
4047
4753
|
const page = await getPage(config);
|
|
4048
4754
|
const max = Math.min(Math.max(Number(args.max ?? 60) || 60, 1), 200);
|
|
4049
|
-
const data = await page.evaluate(([maxN, optionSelector]) => {
|
|
4755
|
+
const data = await page.evaluate(([maxN, optionSelector, formItemSel, formLabelSel]) => {
|
|
4050
4756
|
/** 提取元素的关联 label:aria-label → label[for] → form-item 标签(antd/element)。 */
|
|
4051
4757
|
function labelOf(el) {
|
|
4052
4758
|
const aria = el.getAttribute("aria-label");
|
|
@@ -4055,7 +4761,7 @@ async function elementsTool(config, args) {
|
|
|
4055
4761
|
const text = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
|
|
4056
4762
|
if (text) return text;
|
|
4057
4763
|
}
|
|
4058
|
-
const formLabel = el.closest(
|
|
4764
|
+
const formLabel = el.closest(formItemSel)?.querySelector(formLabelSel)?.textContent?.trim();
|
|
4059
4765
|
if (formLabel) return formLabel.replace(/[*::]\s*$/, "");
|
|
4060
4766
|
return "";
|
|
4061
4767
|
}
|
|
@@ -4087,7 +4793,12 @@ async function elementsTool(config, args) {
|
|
|
4087
4793
|
elements: out,
|
|
4088
4794
|
dropdownOptions: Array.from(document.querySelectorAll(optionSelector)).map((el) => (el.textContent ?? "").trim()).filter(Boolean).slice(0, 50)
|
|
4089
4795
|
};
|
|
4090
|
-
}, [
|
|
4796
|
+
}, [
|
|
4797
|
+
max,
|
|
4798
|
+
DROPDOWN_OPTION_SELECTOR,
|
|
4799
|
+
GENERIC.formItemContainersStrict,
|
|
4800
|
+
GENERIC.formLabelSimple
|
|
4801
|
+
]);
|
|
4091
4802
|
return {
|
|
4092
4803
|
count: data.elements.length,
|
|
4093
4804
|
elements: data.elements,
|
|
@@ -4264,6 +4975,8 @@ async function enrichSemanticMiss(e, config, exec) {
|
|
|
4264
4975
|
}
|
|
4265
4976
|
async function clickTool(config, args, exec) {
|
|
4266
4977
|
const page = await getPage(config);
|
|
4978
|
+
await assertExpectedUrl(page, args.expectUrl, "click");
|
|
4979
|
+
const extWarning = drainExternalNavWarning(page);
|
|
4267
4980
|
let targetRes;
|
|
4268
4981
|
try {
|
|
4269
4982
|
targetRes = await resolveTargetLocator(page, args);
|
|
@@ -4366,6 +5079,7 @@ async function clickTool(config, args, exec) {
|
|
|
4366
5079
|
clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
|
|
4367
5080
|
effect,
|
|
4368
5081
|
...quiet ? { effectHint: "点击后未观察到任何 DOM/URL/网络变化,可能未生效(被遮挡/事件被吞/前置条件未满足);建议 browser_snapshot 核实或换目标" } : {},
|
|
5082
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4369
5083
|
...ambiguityFields(targetRes),
|
|
4370
5084
|
...targetRes.viaBridge === true ? { viaBridge: true } : {},
|
|
4371
5085
|
...await pageIdentity(page)
|
|
@@ -4373,6 +5087,7 @@ async function clickTool(config, args, exec) {
|
|
|
4373
5087
|
}
|
|
4374
5088
|
return {
|
|
4375
5089
|
clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
|
|
5090
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4376
5091
|
...ambiguityFields(targetRes),
|
|
4377
5092
|
...targetRes.viaBridge === true ? { viaBridge: true } : {},
|
|
4378
5093
|
...await pageIdentity(page)
|
|
@@ -4380,6 +5095,8 @@ async function clickTool(config, args, exec) {
|
|
|
4380
5095
|
}
|
|
4381
5096
|
async function typeTool(config, args) {
|
|
4382
5097
|
const page = await getPage(config);
|
|
5098
|
+
await assertExpectedUrl(page, args.expectUrl, "type");
|
|
5099
|
+
const extWarning = drainExternalNavWarning(page);
|
|
4383
5100
|
const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
|
|
4384
5101
|
"textbox",
|
|
4385
5102
|
"searchbox",
|
|
@@ -4394,27 +5111,54 @@ async function typeTool(config, args) {
|
|
|
4394
5111
|
const selector = String(args.ref ?? args.selector ?? "");
|
|
4395
5112
|
if (await fillEditorHelper(page, selector, text)) return {
|
|
4396
5113
|
typed: true,
|
|
4397
|
-
|
|
5114
|
+
typedVia: "editor",
|
|
4398
5115
|
selector,
|
|
4399
5116
|
...ambiguityFields(targetRes),
|
|
4400
5117
|
...await pageIdentity(page)
|
|
4401
5118
|
};
|
|
4402
5119
|
}
|
|
5120
|
+
let typedVia = "clicked";
|
|
5121
|
+
let focusedElement;
|
|
4403
5122
|
if (args.clear === true) {
|
|
4404
5123
|
if (isHuman) {
|
|
4405
5124
|
await target.fill("");
|
|
4406
5125
|
await humanType(page, text, { speed: config.humanizeSpeed });
|
|
4407
5126
|
} else await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
5127
|
+
typedVia = "fill";
|
|
4408
5128
|
} else {
|
|
4409
5129
|
const ambiguous = args.strict === true || (targetRes.matchedCount ?? 0) > 1;
|
|
5130
|
+
let clickOk = false;
|
|
4410
5131
|
try {
|
|
4411
5132
|
await target.click({ timeout: Math.min(args.timeoutMs ?? config.timeoutMs, 5e3) });
|
|
5133
|
+
clickOk = true;
|
|
4412
5134
|
} catch (e) {
|
|
4413
5135
|
if (ambiguous) throw new Error(`type 定位目标存在歧义(命中 ${targetRes.matchedCount ?? "?"} 个元素)且首个不可交互,已中止;请用 scope 限定或 browser_snapshot 取 ref 消歧;原始错误: ${e instanceof Error ? e.message : String(e)}`);
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
5136
|
+
try {
|
|
5137
|
+
await target.click({
|
|
5138
|
+
timeout: 2e3,
|
|
5139
|
+
force: true
|
|
5140
|
+
});
|
|
5141
|
+
clickOk = true;
|
|
5142
|
+
} catch {}
|
|
5143
|
+
}
|
|
5144
|
+
if (!clickOk) {
|
|
5145
|
+
const probe = await withTimeout(target.evaluate((el) => {
|
|
5146
|
+
const ae = document.activeElement;
|
|
5147
|
+
if (ae === null) return {
|
|
5148
|
+
owns: false,
|
|
5149
|
+
desc: null
|
|
5150
|
+
};
|
|
5151
|
+
return {
|
|
5152
|
+
owns: ae === el || el.contains(ae),
|
|
5153
|
+
desc: ae.outerHTML.slice(0, 200)
|
|
5154
|
+
};
|
|
5155
|
+
}), 2e3, "焦点归属探测").catch(() => ({
|
|
5156
|
+
owns: false,
|
|
5157
|
+
desc: null
|
|
5158
|
+
}));
|
|
5159
|
+
focusedElement = probe.desc ?? void 0;
|
|
5160
|
+
if (!probe.owns) throw new Error(`type 目标不可点击且焦点不在目标上,已中止输入(避免盲打到错误输入框)。当前焦点: ${focusedElement ?? "(无法探测/无焦点)"};目标: ${String(args.ref ?? args.selector ?? args.what ?? "")}。建议 browser_snapshot 核实目标状态,或 browser_wait_for 等元素可交互`);
|
|
5161
|
+
typedVia = "focus-kept";
|
|
4418
5162
|
}
|
|
4419
5163
|
if (isHuman) await humanType(page, text, { speed: config.humanizeSpeed });
|
|
4420
5164
|
else await page.keyboard.type(text, { delay: args.delayMs ?? 0 });
|
|
@@ -4422,6 +5166,9 @@ async function typeTool(config, args) {
|
|
|
4422
5166
|
if (args.submit === true) await page.keyboard.press("Enter");
|
|
4423
5167
|
return {
|
|
4424
5168
|
typed: true,
|
|
5169
|
+
typedVia,
|
|
5170
|
+
...focusedElement !== void 0 ? { focusedElement } : {},
|
|
5171
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4425
5172
|
selector: String(args.ref ?? args.selector),
|
|
4426
5173
|
submit: args.submit === true,
|
|
4427
5174
|
...ambiguityFields(targetRes),
|
|
@@ -4432,6 +5179,8 @@ async function pressTool(config, args) {
|
|
|
4432
5179
|
const page = await getPage(config);
|
|
4433
5180
|
const key = String(args.key);
|
|
4434
5181
|
let warning;
|
|
5182
|
+
const extWarning = drainExternalNavWarning(page);
|
|
5183
|
+
if (extWarning !== void 0) warning = warning !== void 0 ? `${warning};${extWarning}` : extWarning;
|
|
4435
5184
|
if (key === "Escape" && args.selector === void 0 && args.ref === void 0) {
|
|
4436
5185
|
const checkState = await page.evaluate(() => {
|
|
4437
5186
|
return {
|
|
@@ -4475,6 +5224,8 @@ async function pressTool(config, args) {
|
|
|
4475
5224
|
*/
|
|
4476
5225
|
async function selectTool(config, args) {
|
|
4477
5226
|
const page = await getPage(config);
|
|
5227
|
+
await assertExpectedUrl(page, args.expectUrl, "select");
|
|
5228
|
+
const extWarning = drainExternalNavWarning(page);
|
|
4478
5229
|
const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
|
|
4479
5230
|
"combobox",
|
|
4480
5231
|
"select",
|
|
@@ -4497,6 +5248,7 @@ async function selectTool(config, args) {
|
|
|
4497
5248
|
clear: args.clear === true
|
|
4498
5249
|
})).selected,
|
|
4499
5250
|
selector: label,
|
|
5251
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4500
5252
|
...ambiguityFields(targetRes),
|
|
4501
5253
|
...await pageIdentity(page)
|
|
4502
5254
|
};
|
|
@@ -4504,8 +5256,8 @@ async function selectTool(config, args) {
|
|
|
4504
5256
|
async function evalTool(config, args) {
|
|
4505
5257
|
const page = await getPage(config);
|
|
4506
5258
|
const { script, unwrapped } = unwrapDoubleEncodedScript(String(args.script ?? ""));
|
|
4507
|
-
const
|
|
4508
|
-
const raw = await page.evaluate(
|
|
5259
|
+
const { code, mode } = buildEvalScript(script);
|
|
5260
|
+
const raw = await page.evaluate(code);
|
|
4509
5261
|
let result;
|
|
4510
5262
|
try {
|
|
4511
5263
|
result = JSON.parse(JSON.stringify(raw ?? null));
|
|
@@ -4514,6 +5266,7 @@ async function evalTool(config, args) {
|
|
|
4514
5266
|
}
|
|
4515
5267
|
return {
|
|
4516
5268
|
result,
|
|
5269
|
+
mode,
|
|
4517
5270
|
...unwrapped ? { scriptUnwrapped: true } : {}
|
|
4518
5271
|
};
|
|
4519
5272
|
}
|
|
@@ -4714,6 +5467,41 @@ async function formFillTool(config, args) {
|
|
|
4714
5467
|
...submitted !== void 0 ? { submitted } : {}
|
|
4715
5468
|
};
|
|
4716
5469
|
}
|
|
5470
|
+
/**
|
|
5471
|
+
* browser_drag:在页面里把源元素拖到目标元素上。
|
|
5472
|
+
* 源/目标各自走统一定位管线(selector / ref / what+scope / mark 四选一前缀),
|
|
5473
|
+
* 支持 HTML5 原生拖放(dragTo)与鼠标拖动类控件(物理鼠标序列降级);
|
|
5474
|
+
* 跨 Frame 拖拽自动绕过 dragTo 的同 Frame 限制。
|
|
5475
|
+
*/
|
|
5476
|
+
async function dragTool(config, args) {
|
|
5477
|
+
const pickSide = (side) => {
|
|
5478
|
+
const remapped = {};
|
|
5479
|
+
if (typeof args[side] === "string" && args[side] !== "") remapped.selector = args[side];
|
|
5480
|
+
if (typeof args[`${side}Ref`] === "string" && args[`${side}Ref`] !== "") remapped.ref = args[`${side}Ref`];
|
|
5481
|
+
if (typeof args[`${side}What`] === "string" && args[`${side}What`].trim() !== "") remapped.what = args[`${side}What`];
|
|
5482
|
+
if (typeof args[`${side}Mark`] === "number") remapped.mark = args[`${side}Mark`];
|
|
5483
|
+
if (typeof args[`${side}Scope`] === "string" && args[`${side}Scope`] !== "") remapped.scope = args[`${side}Scope`];
|
|
5484
|
+
if (Object.keys(remapped).filter((k) => k !== "scope").length === 0) throw new Error(`drag 需要 ${side} 定位: ${side}(CSS 选择器)/${side}Ref(aria ref)/${side}What(意图语义)/${side}Mark(SoM 标徽)至少给一个`);
|
|
5485
|
+
return remapped;
|
|
5486
|
+
};
|
|
5487
|
+
const describeSide = (remapped) => remapped.what !== void 0 ? `what:"${remapped.what}"` : remapped.ref !== void 0 ? `ref:${remapped.ref}` : remapped.mark !== void 0 ? `mark:${remapped.mark}` : String(remapped.selector);
|
|
5488
|
+
const srcArgs = pickSide("source");
|
|
5489
|
+
const tgtArgs = pickSide("target");
|
|
5490
|
+
const page = await getPage(config);
|
|
5491
|
+
const srcRes = await resolveTargetLocator(page, srcArgs);
|
|
5492
|
+
const tgtRes = await resolveTargetLocator(page, tgtArgs);
|
|
5493
|
+
const crossFrame = srcRes.container !== tgtRes.container;
|
|
5494
|
+
return {
|
|
5495
|
+
dragged: true,
|
|
5496
|
+
via: await dragAndDropLocators(page, srcRes.locator, tgtRes.locator, {
|
|
5497
|
+
timeoutMs: typeof args.timeoutMs === "number" ? args.timeoutMs : void 0,
|
|
5498
|
+
preferMouse: crossFrame
|
|
5499
|
+
}),
|
|
5500
|
+
source: describeSide(srcArgs),
|
|
5501
|
+
target: describeSide(tgtArgs),
|
|
5502
|
+
...await pageIdentity(page)
|
|
5503
|
+
};
|
|
5504
|
+
}
|
|
4717
5505
|
//#endregion
|
|
4718
5506
|
//#region lib/types/browser/tools/testing.js
|
|
4719
5507
|
async function waitTool(config, args) {
|
|
@@ -5699,6 +6487,7 @@ async function pauseForHumanTool(config, args) {
|
|
|
5699
6487
|
async function confirmTool(config, args) {
|
|
5700
6488
|
const reason = typeof args.reason === "string" ? args.reason.trim() : "";
|
|
5701
6489
|
if (!reason) throw new Error("缺少 reason: 请说明要执行什么敏感操作、为什么需要审批(如「提交白名单表单,会产生线上数据变更」)");
|
|
6490
|
+
if (!isInteractivePanelPresent()) throw new Error("敏感操作审批失败: 当前环境无前端面板支持人工审批(已按安全策略 fail-closed 拒绝)");
|
|
5702
6491
|
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? config.confirmTimeoutMs ?? 6e4) || 6e4, 1e3), 6e5);
|
|
5703
6492
|
const id = `cfm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
5704
6493
|
setPendingConfirm({
|
|
@@ -5768,6 +6557,14 @@ const describePoint = injectWalker((payload, w) => {
|
|
|
5768
6557
|
*/
|
|
5769
6558
|
async function pickToolOnPage(page, config, args) {
|
|
5770
6559
|
const instruction = typeof args.instruction === "string" && args.instruction.trim() !== "" ? args.instruction.trim() : "请在实时画面大图上点击要教学的目标元素";
|
|
6560
|
+
if (!isInteractivePanelPresent()) return {
|
|
6561
|
+
picked: false,
|
|
6562
|
+
status: "missed",
|
|
6563
|
+
instruction,
|
|
6564
|
+
detail: "面板点选教学失败: 当前环境无前端面板供用户交互式点选",
|
|
6565
|
+
waitedMs: 0,
|
|
6566
|
+
...await pageIdentity(page)
|
|
6567
|
+
};
|
|
5771
6568
|
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 3e5) || 3e5, 1e3), 6e5);
|
|
5772
6569
|
const id = `pick-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
5773
6570
|
const meta = screencastManager.getLastFrame()?.metadata ?? void 0;
|
|
@@ -7591,7 +8388,11 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7591
8388
|
type: "boolean",
|
|
7592
8389
|
description: "多命中即抛错(对齐 Playwright strict),要求定位唯一;默认 false 操作第 1 个并在返回中提示命中数。"
|
|
7593
8390
|
},
|
|
7594
|
-
timeoutMs: { type: "integer" }
|
|
8391
|
+
timeoutMs: { type: "integer" },
|
|
8392
|
+
expectUrl: {
|
|
8393
|
+
type: "string",
|
|
8394
|
+
description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
|
|
8395
|
+
}
|
|
7595
8396
|
},
|
|
7596
8397
|
additionalProperties: false
|
|
7597
8398
|
},
|
|
@@ -7619,6 +8420,10 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7619
8420
|
viaBridge: {
|
|
7620
8421
|
type: "boolean",
|
|
7621
8422
|
description: "what 未命中时经页面 __DSH_BRIDGE__(vite-plugin-dsh-bridge)兜底命中。"
|
|
8423
|
+
},
|
|
8424
|
+
warning: {
|
|
8425
|
+
type: "string",
|
|
8426
|
+
description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
|
|
7622
8427
|
}
|
|
7623
8428
|
},
|
|
7624
8429
|
required: [
|
|
@@ -7633,7 +8438,7 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7633
8438
|
}),
|
|
7634
8439
|
definePlainTool({
|
|
7635
8440
|
name: "browser_type",
|
|
7636
|
-
description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror
|
|
8441
|
+
description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。定位与点击均失败时做焦点归属校验:焦点不在目标内(含子元素)则中止输入并附当前焦点线索,不会盲打到错误输入框;返回 typedVia 标明实际输入通道。expectUrl 传入时先校验当前页面 URL,不符直接报错(防页面被切换后打错位置)。",
|
|
7637
8442
|
parameters: {
|
|
7638
8443
|
type: "object",
|
|
7639
8444
|
properties: {
|
|
@@ -7669,7 +8474,11 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7669
8474
|
description: "多命中即抛错(对齐 Playwright strict);多命中或 strict 时不再 force 降级点击,遮罩拦截会直接暴露。"
|
|
7670
8475
|
},
|
|
7671
8476
|
delayMs: { type: "integer" },
|
|
7672
|
-
timeoutMs: { type: "integer" }
|
|
8477
|
+
timeoutMs: { type: "integer" },
|
|
8478
|
+
expectUrl: {
|
|
8479
|
+
type: "string",
|
|
8480
|
+
description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
|
|
8481
|
+
}
|
|
7673
8482
|
},
|
|
7674
8483
|
required: ["text"],
|
|
7675
8484
|
additionalProperties: false
|
|
@@ -7679,6 +8488,20 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7679
8488
|
additionalProperties: false,
|
|
7680
8489
|
properties: {
|
|
7681
8490
|
typed: { type: "boolean" },
|
|
8491
|
+
typedVia: {
|
|
8492
|
+
type: "string",
|
|
8493
|
+
enum: [
|
|
8494
|
+
"fill",
|
|
8495
|
+
"clicked",
|
|
8496
|
+
"focus-kept",
|
|
8497
|
+
"editor"
|
|
8498
|
+
],
|
|
8499
|
+
description: "实际输入通道:fill=clear 清空重填;clicked=点击获得焦点;focus-kept=点击超时但焦点已在目标内续打;editor=编辑器适配。"
|
|
8500
|
+
},
|
|
8501
|
+
focusedElement: {
|
|
8502
|
+
type: "string",
|
|
8503
|
+
description: "focus-kept 通道下当前焦点元素 outerHTML 片段(调试线索)。"
|
|
8504
|
+
},
|
|
7682
8505
|
selector: { type: "string" },
|
|
7683
8506
|
submit: { type: "boolean" },
|
|
7684
8507
|
url: { type: "string" },
|
|
@@ -7694,6 +8517,10 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7694
8517
|
hint: {
|
|
7695
8518
|
type: "string",
|
|
7696
8519
|
description: "matchedCount>1 时的消歧指引。"
|
|
8520
|
+
},
|
|
8521
|
+
warning: {
|
|
8522
|
+
type: "string",
|
|
8523
|
+
description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
|
|
7697
8524
|
}
|
|
7698
8525
|
},
|
|
7699
8526
|
required: [
|
|
@@ -7808,7 +8635,11 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7808
8635
|
type: "boolean",
|
|
7809
8636
|
description: "多命中即抛错(对齐 Playwright strict)。"
|
|
7810
8637
|
},
|
|
7811
|
-
timeoutMs: { type: "integer" }
|
|
8638
|
+
timeoutMs: { type: "integer" },
|
|
8639
|
+
expectUrl: {
|
|
8640
|
+
type: "string",
|
|
8641
|
+
description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
|
|
8642
|
+
}
|
|
7812
8643
|
},
|
|
7813
8644
|
required: ["option"],
|
|
7814
8645
|
additionalProperties: false
|
|
@@ -7832,6 +8663,10 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7832
8663
|
hint: {
|
|
7833
8664
|
type: "string",
|
|
7834
8665
|
description: "matchedCount>1 时的消歧指引。"
|
|
8666
|
+
},
|
|
8667
|
+
warning: {
|
|
8668
|
+
type: "string",
|
|
8669
|
+
description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
|
|
7835
8670
|
}
|
|
7836
8671
|
},
|
|
7837
8672
|
required: [
|
|
@@ -7845,9 +8680,95 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7845
8680
|
timeoutMs: timeout(),
|
|
7846
8681
|
execute: (args) => selectTool(config, args)
|
|
7847
8682
|
}),
|
|
8683
|
+
definePlainTool({
|
|
8684
|
+
name: "browser_drag",
|
|
8685
|
+
description: "把源元素拖到目标元素上(拖拽/拖放):源与目标各自定位——source(CSS)/sourceRef(aria ref)/sourceWhat(意图语义,+sourceScope)/sourceMark(SoM 标徽)四选一,target/targetRef/targetWhat(+targetScope)/targetMark 同理。优先 Playwright dragTo(覆盖 HTML5 原生拖放),失败或跨 Frame 时自动降级为物理鼠标序列(覆盖看板卡片、滑块等鼠标拖动类控件),返回 via 标明实际通道。注意:dragged=true 表示拖拽序列已执行,不保证业务侧生效(drop 区拒绝、控件无响应仍为 true),需要验证请配合 browser_assert。源/目标不可见或零命中时抛错并附诊断。",
|
|
8686
|
+
parameters: {
|
|
8687
|
+
type: "object",
|
|
8688
|
+
properties: {
|
|
8689
|
+
source: {
|
|
8690
|
+
type: "string",
|
|
8691
|
+
description: "源元素 CSS 选择器;与 sourceRef/sourceWhat/sourceMark 互斥。"
|
|
8692
|
+
},
|
|
8693
|
+
sourceRef: {
|
|
8694
|
+
type: "string",
|
|
8695
|
+
description: "源元素 aria 快照 ref(如 'e3');与 source/sourceWhat/sourceMark 互斥。"
|
|
8696
|
+
},
|
|
8697
|
+
sourceWhat: {
|
|
8698
|
+
type: "string",
|
|
8699
|
+
description: "源元素意图描述(如 '任务卡片 A'):页内打分语义定位;与 source/sourceRef/sourceMark 互斥;配合 sourceScope。"
|
|
8700
|
+
},
|
|
8701
|
+
sourceMark: {
|
|
8702
|
+
type: "integer",
|
|
8703
|
+
description: "源元素 SoM 标徽编号(browser_screenshot { som: true });与 source/sourceRef/sourceWhat 互斥。"
|
|
8704
|
+
},
|
|
8705
|
+
sourceScope: {
|
|
8706
|
+
type: "string",
|
|
8707
|
+
description: "配合 sourceWhat 限定扫描范围的容器选择器。"
|
|
8708
|
+
},
|
|
8709
|
+
target: {
|
|
8710
|
+
type: "string",
|
|
8711
|
+
description: "目标元素 CSS 选择器;与 targetRef/targetWhat/targetMark 互斥。"
|
|
8712
|
+
},
|
|
8713
|
+
targetRef: {
|
|
8714
|
+
type: "string",
|
|
8715
|
+
description: "目标元素 aria 快照 ref;与 target/targetWhat/targetMark 互斥。"
|
|
8716
|
+
},
|
|
8717
|
+
targetWhat: {
|
|
8718
|
+
type: "string",
|
|
8719
|
+
description: "目标元素意图描述(如 '待办列'):页内打分语义定位;与 target/targetRef/targetMark 互斥;配合 targetScope。"
|
|
8720
|
+
},
|
|
8721
|
+
targetMark: {
|
|
8722
|
+
type: "integer",
|
|
8723
|
+
description: "目标元素 SoM 标徽编号;与 target/targetRef/targetWhat 互斥。"
|
|
8724
|
+
},
|
|
8725
|
+
targetScope: {
|
|
8726
|
+
type: "string",
|
|
8727
|
+
description: "配合 targetWhat 限定扫描范围的容器选择器。"
|
|
8728
|
+
},
|
|
8729
|
+
timeoutMs: { type: "integer" }
|
|
8730
|
+
},
|
|
8731
|
+
additionalProperties: false
|
|
8732
|
+
},
|
|
8733
|
+
outputSchema: {
|
|
8734
|
+
type: "object",
|
|
8735
|
+
additionalProperties: false,
|
|
8736
|
+
properties: {
|
|
8737
|
+
dragged: {
|
|
8738
|
+
type: "boolean",
|
|
8739
|
+
description: "拖拽序列已执行(true 不保证业务侧生效,drop 被拒绝也为 true)。"
|
|
8740
|
+
},
|
|
8741
|
+
via: {
|
|
8742
|
+
type: "string",
|
|
8743
|
+
description: "实际执行通道:'dragTo'(HTML5/组件库)或 'mouse'(物理鼠标,含跨 Frame 与降级)。"
|
|
8744
|
+
},
|
|
8745
|
+
source: {
|
|
8746
|
+
type: "string",
|
|
8747
|
+
description: "实际使用的源定位描述(what:/ref:/mark:/selector)。"
|
|
8748
|
+
},
|
|
8749
|
+
target: {
|
|
8750
|
+
type: "string",
|
|
8751
|
+
description: "实际使用的目标定位描述。"
|
|
8752
|
+
},
|
|
8753
|
+
url: { type: "string" },
|
|
8754
|
+
title: { type: "string" }
|
|
8755
|
+
},
|
|
8756
|
+
required: [
|
|
8757
|
+
"dragged",
|
|
8758
|
+
"via",
|
|
8759
|
+
"source",
|
|
8760
|
+
"target",
|
|
8761
|
+
"url",
|
|
8762
|
+
"title"
|
|
8763
|
+
]
|
|
8764
|
+
},
|
|
8765
|
+
render: (_args, value) => `Dragged ${value.source} → ${value.target} (via ${value.via})`,
|
|
8766
|
+
timeoutMs: timeout(),
|
|
8767
|
+
execute: (args) => dragTool(config, args)
|
|
8768
|
+
}),
|
|
7848
8769
|
definePlainTool({
|
|
7849
8770
|
name: "browser_eval",
|
|
7850
|
-
description: "在页面上下文执行一段 JavaScript
|
|
8771
|
+
description: "在页面上下文执行一段 JavaScript。表达式(含多行对象字面量/箭头函数 IIFE/顶层 await)直接返回值;语句脚本自动 return 最后一个顶层表达式的值,也可显式 return。结果必须是可 JSON 序列化的值,返回 mode 标明实际执行形态。",
|
|
7851
8772
|
parameters: {
|
|
7852
8773
|
type: "object",
|
|
7853
8774
|
properties: { script: {
|
|
@@ -7862,6 +8783,15 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7862
8783
|
additionalProperties: false,
|
|
7863
8784
|
properties: {
|
|
7864
8785
|
result: {},
|
|
8786
|
+
mode: {
|
|
8787
|
+
type: "string",
|
|
8788
|
+
enum: [
|
|
8789
|
+
"expression",
|
|
8790
|
+
"statements-auto-return",
|
|
8791
|
+
"statements"
|
|
8792
|
+
],
|
|
8793
|
+
description: "实际执行形态:expression=表达式直取值(支持顶层 await 与多行对象字面量);statements-auto-return=语句体自动 return 最后一个顶层表达式;statements=语句体(显式 return 或纯副作用,无返回值时 result 为 null)。"
|
|
8794
|
+
},
|
|
7865
8795
|
scriptUnwrapped: {
|
|
7866
8796
|
type: "boolean",
|
|
7867
8797
|
description: "入参 script 疑似被 JSON 双重序列化(以引号包裹且含 \\n 字面量),已自动解码一层。"
|
|
@@ -9340,14 +10270,16 @@ function registerTools(ctx, config) {
|
|
|
9340
10270
|
"browser_upload",
|
|
9341
10271
|
"browser_form",
|
|
9342
10272
|
"browser_form_fill",
|
|
9343
|
-
"browser_eval"
|
|
10273
|
+
"browser_eval",
|
|
10274
|
+
"browser_drag"
|
|
9344
10275
|
]);
|
|
9345
10276
|
/** 操作预告:这些工具执行前把目标元素在实时画面上高亮(异步不阻塞)。 */
|
|
9346
10277
|
const PREVIEW_TOOLS = /* @__PURE__ */ new Set([
|
|
9347
10278
|
"browser_click",
|
|
9348
10279
|
"browser_type",
|
|
9349
10280
|
"browser_select",
|
|
9350
|
-
"browser_press"
|
|
10281
|
+
"browser_press",
|
|
10282
|
+
"browser_drag"
|
|
9351
10283
|
]);
|
|
9352
10284
|
/**
|
|
9353
10285
|
* auto 审批闸(confirmMode:"auto" 硬约束):live 调用在 register 包装层拦,
|
|
@@ -9402,8 +10334,8 @@ function registerTools(ctx, config) {
|
|
|
9402
10334
|
sessionTracer.startStep(currentProfile, def.name, args, activePage());
|
|
9403
10335
|
if (PREVIEW_TOOLS.has(def.name)) {
|
|
9404
10336
|
const page = activePage();
|
|
9405
|
-
const selector = typeof args?.selector === "string" && args.selector ? args.selector : typeof args?.ref === "string" && args.ref && page !== null ? pageRef(page, args.ref)?.selector ?? "" : "";
|
|
9406
|
-
if (selector !== "") previewHighlight(page, selector, `${def.name.replace(/^browser_/, "")} ${args?.ref ? `[${args.ref}] ` : ""}${args?.key ?? args?.value ?? args?.text ?? ""}`.trim());
|
|
10337
|
+
const selector = typeof args?.selector === "string" && args.selector ? args.selector : def.name === "browser_drag" && typeof args?.source === "string" && args.source ? args.source : typeof args?.ref === "string" && args.ref && page !== null ? pageRef(page, args.ref)?.selector ?? "" : "";
|
|
10338
|
+
if (selector !== "") previewHighlight(page, selector, `${def.name.replace(/^browser_/, "")} ${args?.ref ? `[${args.ref}] ` : ""}${args?.key ?? args?.value ?? args?.text ?? args?.source ?? ""}`.trim());
|
|
9407
10339
|
}
|
|
9408
10340
|
try {
|
|
9409
10341
|
const result = await original(args, exec);
|
|
@@ -9448,6 +10380,7 @@ function registerTools(ctx, config) {
|
|
|
9448
10380
|
browser_type: (a) => typeTool(config, a),
|
|
9449
10381
|
browser_press: (a) => pressTool(config, a),
|
|
9450
10382
|
browser_select: (a) => selectTool(config, a),
|
|
10383
|
+
browser_drag: (a) => dragTool(config, a),
|
|
9451
10384
|
browser_eval: (a) => evalTool(config, a),
|
|
9452
10385
|
browser_screenshot: (a) => screenshotTool(config, a),
|
|
9453
10386
|
browser_wait: (a) => waitTool(config, a),
|
|
@@ -9767,136 +10700,8 @@ function registerBrowserApi(ctx, config, base) {
|
|
|
9767
10700
|
//#region lib/types/index.js
|
|
9768
10701
|
const name = "dsh-browser-view";
|
|
9769
10702
|
const inject = [];
|
|
9770
|
-
const SCHEMA = {
|
|
9771
|
-
enabled: {
|
|
9772
|
-
type: "boolean",
|
|
9773
|
-
optional: true
|
|
9774
|
-
},
|
|
9775
|
-
channel: { type: "string" },
|
|
9776
|
-
executablePath: { type: "string" },
|
|
9777
|
-
headless: { type: "boolean" },
|
|
9778
|
-
userDataDir: { type: "string" },
|
|
9779
|
-
profiles: { type: "any" },
|
|
9780
|
-
screenshotDir: { type: "string" },
|
|
9781
|
-
downloadDir: { type: "string" },
|
|
9782
|
-
workspaceDir: { type: "string" },
|
|
9783
|
-
screenshotMaxAgeDays: {
|
|
9784
|
-
type: "number",
|
|
9785
|
-
min: 0
|
|
9786
|
-
},
|
|
9787
|
-
screenshotMaxCount: {
|
|
9788
|
-
type: "number",
|
|
9789
|
-
min: 0
|
|
9790
|
-
},
|
|
9791
|
-
basePath: { type: "string" },
|
|
9792
|
-
recordBodies: { type: "boolean" },
|
|
9793
|
-
maxTextChars: {
|
|
9794
|
-
type: "number",
|
|
9795
|
-
min: 1
|
|
9796
|
-
},
|
|
9797
|
-
maxLinks: {
|
|
9798
|
-
type: "number",
|
|
9799
|
-
min: 1
|
|
9800
|
-
},
|
|
9801
|
-
timeoutMs: {
|
|
9802
|
-
type: "number",
|
|
9803
|
-
min: 1
|
|
9804
|
-
},
|
|
9805
|
-
baselineDir: { type: "string" },
|
|
9806
|
-
selectorHealing: { type: "boolean" },
|
|
9807
|
-
healTimeoutMs: {
|
|
9808
|
-
type: "number",
|
|
9809
|
-
min: 100
|
|
9810
|
-
},
|
|
9811
|
-
maxRefs: {
|
|
9812
|
-
type: "number",
|
|
9813
|
-
min: 1
|
|
9814
|
-
},
|
|
9815
|
-
maxRows: {
|
|
9816
|
-
type: "number",
|
|
9817
|
-
min: 1
|
|
9818
|
-
},
|
|
9819
|
-
snapshotHistory: {
|
|
9820
|
-
type: "number",
|
|
9821
|
-
min: 1,
|
|
9822
|
-
max: 20
|
|
9823
|
-
},
|
|
9824
|
-
confirmMode: {
|
|
9825
|
-
type: "string",
|
|
9826
|
-
enum: [
|
|
9827
|
-
"off",
|
|
9828
|
-
"manual",
|
|
9829
|
-
"auto"
|
|
9830
|
-
]
|
|
9831
|
-
},
|
|
9832
|
-
confirmTimeoutMs: {
|
|
9833
|
-
type: "number",
|
|
9834
|
-
min: 1e3
|
|
9835
|
-
},
|
|
9836
|
-
sensitivePatterns: { type: "string[]" },
|
|
9837
|
-
sensitiveTools: { type: "string[]" },
|
|
9838
|
-
confirmPolicies: {
|
|
9839
|
-
type: "any",
|
|
9840
|
-
optional: true
|
|
9841
|
-
},
|
|
9842
|
-
humanize: {
|
|
9843
|
-
type: "any",
|
|
9844
|
-
optional: true
|
|
9845
|
-
},
|
|
9846
|
-
humanizeSpeed: {
|
|
9847
|
-
type: "string",
|
|
9848
|
-
enum: [
|
|
9849
|
-
"slow",
|
|
9850
|
-
"normal",
|
|
9851
|
-
"fast"
|
|
9852
|
-
],
|
|
9853
|
-
optional: true
|
|
9854
|
-
},
|
|
9855
|
-
confirmInReplay: { type: "boolean" },
|
|
9856
|
-
somFallback: { type: "boolean" },
|
|
9857
|
-
refReanchor: { type: "boolean" },
|
|
9858
|
-
toolset: {
|
|
9859
|
-
type: "string",
|
|
9860
|
-
enum: ["core", "all"]
|
|
9861
|
-
}
|
|
9862
|
-
};
|
|
9863
|
-
const DEFAULT_CONFIG = {
|
|
9864
|
-
channel: "auto",
|
|
9865
|
-
executablePath: "",
|
|
9866
|
-
headless: false,
|
|
9867
|
-
userDataDir: "",
|
|
9868
|
-
profiles: {},
|
|
9869
|
-
screenshotDir: ".dsh-browser/screenshots",
|
|
9870
|
-
downloadDir: ".dsh-browser/downloads",
|
|
9871
|
-
workspaceDir: "",
|
|
9872
|
-
screenshotMaxAgeDays: 7,
|
|
9873
|
-
screenshotMaxCount: 200,
|
|
9874
|
-
basePath: "/browser",
|
|
9875
|
-
recordBodies: true,
|
|
9876
|
-
maxTextChars: 2e4,
|
|
9877
|
-
maxLinks: 50,
|
|
9878
|
-
timeoutMs: 3e4,
|
|
9879
|
-
baselineDir: ".dsh-browser/baselines",
|
|
9880
|
-
selectorHealing: true,
|
|
9881
|
-
healTimeoutMs: 3e3,
|
|
9882
|
-
maxRefs: 200,
|
|
9883
|
-
maxRows: 1e3,
|
|
9884
|
-
snapshotHistory: 5,
|
|
9885
|
-
confirmMode: "off",
|
|
9886
|
-
confirmTimeoutMs: 6e4,
|
|
9887
|
-
sensitivePatterns: [
|
|
9888
|
-
"提交",
|
|
9889
|
-
"删除",
|
|
9890
|
-
"支付"
|
|
9891
|
-
],
|
|
9892
|
-
sensitiveTools: ["browser_click"],
|
|
9893
|
-
confirmInReplay: true,
|
|
9894
|
-
somFallback: true,
|
|
9895
|
-
refReanchor: true,
|
|
9896
|
-
toolset: "all"
|
|
9897
|
-
};
|
|
9898
10703
|
function apply(ctx, rawConfig) {
|
|
9899
|
-
const resolved = resolveConfig("dsh-browser-view",
|
|
10704
|
+
const resolved = resolveConfig("dsh-browser-view", BROWSER_CONFIG_SCHEMA, BROWSER_DEFAULT_CONFIG, rawConfig);
|
|
9900
10705
|
if (resolved.enabled === false) return;
|
|
9901
10706
|
const config = resolved;
|
|
9902
10707
|
const base = (config.basePath ?? "/browser").replace(/\/+$/, "");
|
|
@@ -9912,11 +10717,11 @@ function apply(ctx, rawConfig) {
|
|
|
9912
10717
|
const bindPrompt = (promptCtx) => {
|
|
9913
10718
|
const systemPrompt = promptCtx.get("systemPrompt") ?? promptCtx.systemPrompt;
|
|
9914
10719
|
if (systemPrompt === void 0) return;
|
|
9915
|
-
const
|
|
10720
|
+
const toolPrompt = browserSystemPrompt(config);
|
|
9916
10721
|
const sectionDispose = systemPrompt?.section?.({
|
|
9917
10722
|
name: "tool:browser",
|
|
9918
10723
|
order: 200,
|
|
9919
|
-
text:
|
|
10724
|
+
text: toolPrompt
|
|
9920
10725
|
});
|
|
9921
10726
|
if (sectionDispose !== void 0) promptCtx.effect(() => sectionDispose);
|
|
9922
10727
|
};
|