dsh-browser-view 0.8.0 → 0.8.2
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 +12 -9
- package/lib/client.js +1 -1
- package/lib/index.js +945 -181
- package/lib/mcp.js +924 -181
- 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 +13 -0
- package/lib/types/browser/tools/collaboration.d.ts +16 -13
- 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/client/api.d.ts +4 -1
- package/lib/types/client/components/LoginWaitCard.d.ts +30 -0
- package/lib/types/client/hooks/useLoginWaitPolling.d.ts +6 -0
- package/lib/types/client/types.d.ts +7 -0
- package/lib/types/lib/prompts.d.ts +2 -2
- package/package.json +2 -1
package/lib/mcp.js
CHANGED
|
@@ -184,8 +184,8 @@ const BROWSER_DEFAULT_CONFIG = {
|
|
|
184
184
|
};
|
|
185
185
|
//#endregion
|
|
186
186
|
//#region lib/types/lib/prompts.js
|
|
187
|
-
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
|
|
188
|
-
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。用户可通过界面右下角面板的实时画面查看浏览器。";
|
|
187
|
+
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 时登录态会持久化。用户可通过界面右下角面板的实时画面查看浏览器。";
|
|
188
|
+
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(用户在面板点「我已完成登录」即返回;无面板环境必须给 successSelector/successUrl,否则调用报错),验证码/二次确认用 browser_pause_for_human,敏感动作(提交/删除/支付)前用 browser_confirm。环境异常先跑 browser_doctor 诊断。录制/回放/报告/抽取/profile/隔离 context 等工具当前未注册(toolset=core),需要时把配置改为 toolset: all 并重启 DSH。用户可通过界面右下角面板的实时画面查看浏览器。";
|
|
189
189
|
/** confirmMode 附加指引(与 DSH 入口同款语义,manual 面向宿主审批、auto 说明硬闸)。 */
|
|
190
190
|
function confirmGuidanceOf(confirmMode) {
|
|
191
191
|
if (confirmMode === "manual") return "本环境 confirmMode 为 manual:凡提交/删除/支付等不可逆或影响线上数据的敏感动作,执行前必须先调用 browser_confirm(reason 说明操作与影响,等待用户在实时画面面板点允许/拒绝;拒绝或超时即放弃该动作,不得换路径绕过)。";
|
|
@@ -483,6 +483,33 @@ function snapshotVersionEntry(cache, version) {
|
|
|
483
483
|
if (cache === void 0) return void 0;
|
|
484
484
|
return cache.history[cache.history.length - 1]?.version === version ? cache.history[cache.history.length - 1] : cache.history.find((e) => e.version === version);
|
|
485
485
|
}
|
|
486
|
+
const lastStableNavSeqs = /* @__PURE__ */ new WeakMap();
|
|
487
|
+
/** 记录"最近一次成功操作"的导航代际(pageIdentity 打点,零成本)。 */
|
|
488
|
+
function markStableNav(page) {
|
|
489
|
+
lastStableNavSeqs.set(page, pageNavSeq(page));
|
|
490
|
+
}
|
|
491
|
+
/** 当前代际距最近成功操作代际的差值(≥1 = 期间发生过导航)。 */
|
|
492
|
+
function navSinceStable(page) {
|
|
493
|
+
return pageNavSeq(page) - (lastStableNavSeqs.get(page) ?? pageNavSeq(page));
|
|
494
|
+
}
|
|
495
|
+
const externalNavs = /* @__PURE__ */ new WeakMap();
|
|
496
|
+
/** 记录一次疑似外部导航(非工具窗口内发生的 framenavigated)。 */
|
|
497
|
+
function noteExternalNav(page, url) {
|
|
498
|
+
const list = externalNavs.get(page) ?? [];
|
|
499
|
+
list.push({
|
|
500
|
+
url,
|
|
501
|
+
at: Date.now()
|
|
502
|
+
});
|
|
503
|
+
if (list.length > 5) list.shift();
|
|
504
|
+
externalNavs.set(page, list);
|
|
505
|
+
}
|
|
506
|
+
/** 读取并清空疑似外部导航记录,拼装 warning 文案;无记录返回 undefined。 */
|
|
507
|
+
function drainExternalNavWarning(page) {
|
|
508
|
+
const list = externalNavs.get(page);
|
|
509
|
+
if (list === void 0 || list.length === 0) return void 0;
|
|
510
|
+
externalNavs.delete(page);
|
|
511
|
+
return `检测到非工具触发的页面导航(页面自动跳转或人工操作:${list.map((n) => n.url.slice(0, 80)).join(" → ")}),此前的定位结果可能基于被切换后的页面,请核实当前页面状态`;
|
|
512
|
+
}
|
|
486
513
|
let interactivePanelPresent = true;
|
|
487
514
|
/** 标记当前宿主环境是否挂载了交互面板(DSH 插件=true;独立 MCP 模式=false)。 */
|
|
488
515
|
function setInteractivePanelPresent(present) {
|
|
@@ -501,6 +528,16 @@ function getPendingConfirm() {
|
|
|
501
528
|
function clearPendingConfirm() {
|
|
502
529
|
pendingConfirm = null;
|
|
503
530
|
}
|
|
531
|
+
let pendingLoginWait = null;
|
|
532
|
+
function setPendingLoginWait(entry) {
|
|
533
|
+
pendingLoginWait = entry;
|
|
534
|
+
}
|
|
535
|
+
function getPendingLoginWait() {
|
|
536
|
+
return pendingLoginWait;
|
|
537
|
+
}
|
|
538
|
+
function clearPendingLoginWait() {
|
|
539
|
+
pendingLoginWait = null;
|
|
540
|
+
}
|
|
504
541
|
let pendingPick = null;
|
|
505
542
|
function setPendingPick(entry) {
|
|
506
543
|
pendingPick = entry;
|
|
@@ -822,6 +859,53 @@ function injectWalker(entry) {
|
|
|
822
859
|
const src = `"use strict"; return function (payload) { var __name = (t, v) => t; const w = (${CORE_SRC})(); return (${entry.toString()})(payload, w); };`;
|
|
823
860
|
return new Function(src)();
|
|
824
861
|
}
|
|
862
|
+
const pickTopDialog = injectWalker((payload, w) => {
|
|
863
|
+
const effectiveZ = (el) => {
|
|
864
|
+
let node = el;
|
|
865
|
+
while (node !== null) {
|
|
866
|
+
const cs = getComputedStyle(node);
|
|
867
|
+
if (cs.position !== "static" && cs.zIndex !== "auto") {
|
|
868
|
+
const z = Number.parseInt(cs.zIndex, 10);
|
|
869
|
+
if (Number.isFinite(z)) return z;
|
|
870
|
+
}
|
|
871
|
+
node = w.composedParent(node);
|
|
872
|
+
}
|
|
873
|
+
return 0;
|
|
874
|
+
};
|
|
875
|
+
const visible = (el) => {
|
|
876
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
877
|
+
const cs = getComputedStyle(el);
|
|
878
|
+
if (cs.display === "none" || cs.visibility === "hidden") return false;
|
|
879
|
+
const r = el.getBoundingClientRect();
|
|
880
|
+
return r.width > 0 && r.height > 0;
|
|
881
|
+
};
|
|
882
|
+
const collect = (sel) => {
|
|
883
|
+
try {
|
|
884
|
+
return Array.from(document.querySelectorAll(sel)).filter(visible);
|
|
885
|
+
} catch {
|
|
886
|
+
return [];
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
let tier = "precise";
|
|
890
|
+
let nodes = collect(payload.precise);
|
|
891
|
+
if (nodes.length === 0) {
|
|
892
|
+
tier = "fuzzy";
|
|
893
|
+
nodes = collect(payload.fuzzy);
|
|
894
|
+
}
|
|
895
|
+
if (nodes.length === 0) return null;
|
|
896
|
+
const zs = nodes.map((n) => effectiveZ(n));
|
|
897
|
+
const maxZ = Math.max(...zs);
|
|
898
|
+
const top = nodes.filter((n, i) => zs[i] === maxZ);
|
|
899
|
+
const outermost = top.filter((n) => !top.some((m) => m !== n && m.contains(n)));
|
|
900
|
+
const pool = outermost.length > 0 ? outermost : top;
|
|
901
|
+
const winner = pool[pool.length - 1];
|
|
902
|
+
if (winner === void 0) return null;
|
|
903
|
+
return {
|
|
904
|
+
selector: w.cssPath(winner),
|
|
905
|
+
z: maxZ,
|
|
906
|
+
tier
|
|
907
|
+
};
|
|
908
|
+
});
|
|
825
909
|
//#endregion
|
|
826
910
|
//#region lib/types/browser/session/recorder.js
|
|
827
911
|
/** 已保存的录制脚本:name → 步骤数组。 */
|
|
@@ -1069,15 +1153,109 @@ function cleanupScreenshots(dir, opts) {
|
|
|
1069
1153
|
return removed;
|
|
1070
1154
|
}
|
|
1071
1155
|
//#endregion
|
|
1156
|
+
//#region lib/types/browser/primitives/ui-library.js
|
|
1157
|
+
const GENERIC = {
|
|
1158
|
+
dialogWords: [
|
|
1159
|
+
"modal",
|
|
1160
|
+
"drawer",
|
|
1161
|
+
"dialog"
|
|
1162
|
+
],
|
|
1163
|
+
dialogExcludedParts: [
|
|
1164
|
+
"mask",
|
|
1165
|
+
"wrap",
|
|
1166
|
+
"root",
|
|
1167
|
+
"header",
|
|
1168
|
+
"footer",
|
|
1169
|
+
"title",
|
|
1170
|
+
"close",
|
|
1171
|
+
"body",
|
|
1172
|
+
"btn"
|
|
1173
|
+
],
|
|
1174
|
+
/** 下拉触发器内部件(点它展开) */
|
|
1175
|
+
dropdownTriggerParts: "[class*='selector'], [role='combobox']",
|
|
1176
|
+
/** 下拉浮层容器的子串词(portal 挂载的面板) */
|
|
1177
|
+
dropdownFloatWords: ["select-dropdown", "popper"],
|
|
1178
|
+
/** 通用下拉选项行(select 状态机消费;库精确层另补) */
|
|
1179
|
+
dropdownOptionRows: "[class*='item-option']:not([class*='empty']), [class*='select-dropdown'] li:not([class*='empty'])",
|
|
1180
|
+
/** 可交互「选项」枚举(elements 工具下拉选项清单消费) */
|
|
1181
|
+
dropdownOptionEnumeration: [
|
|
1182
|
+
"[role='option']",
|
|
1183
|
+
"[role='menuitem']",
|
|
1184
|
+
"[class*='select'][class*='dropdown'] [class*='option']",
|
|
1185
|
+
"[class*='select'][class*='dropdown'] [class*='item']",
|
|
1186
|
+
"[role='listbox'] [role='option']"
|
|
1187
|
+
].join(", "),
|
|
1188
|
+
/** 多选标签清除按钮 */
|
|
1189
|
+
dropdownClearButtons: "[class*='clear'], [aria-label*='clear']",
|
|
1190
|
+
/** 多选标签移除按钮 */
|
|
1191
|
+
dropdownRemoveButtons: "[class*='remove'], [class*='close'], [aria-label*='close']",
|
|
1192
|
+
/** 空态指示器 */
|
|
1193
|
+
emptyStates: "[class*='empty'], [class*='no-data'], [role='status'], [class*='item-empty']",
|
|
1194
|
+
/** 表单项容器(宽松:BEM/-item 子串 + group + form 元素;label 反查主用) */
|
|
1195
|
+
formItemContainers: "[class*='form-item'], [class*='form__item'], [role='group'], form, .form-group",
|
|
1196
|
+
/**
|
|
1197
|
+
* 表单项容器(严格:带分隔符前缀,避免 platform-item 之类无关门类误命中;
|
|
1198
|
+
* elements 工具页内 labelOf 消费)
|
|
1199
|
+
*/
|
|
1200
|
+
formItemContainersStrict: "[class*='-form-item'], [class*='__form-item'], form, .form-group",
|
|
1201
|
+
/** 表单项宿主判定(目标是否处于表单语境;含 label 本身) */
|
|
1202
|
+
formItemHosts: "label, [class*='form-item'], [class*='form__item'], [class*='form-group']",
|
|
1203
|
+
/** 表单项 label(dom 原语 label 反查消费) */
|
|
1204
|
+
formItemLabels: "[class*='form-item-label'], [class*='form-item__label'], [class*='label'], label",
|
|
1205
|
+
/** 表单 label(简化集;elements 工具页内 labelOf 消费) */
|
|
1206
|
+
formLabelSimple: "[class*='label'], label"
|
|
1207
|
+
};
|
|
1208
|
+
/**
|
|
1209
|
+
* 注册新库(示例:naive-ui):
|
|
1210
|
+
* const naiveUi: UILibrarySpec = {
|
|
1211
|
+
* name: "naive-ui",
|
|
1212
|
+
* modalContainers: [".n-modal-container"],
|
|
1213
|
+
* drawerContainers: [".n-drawer-container"],
|
|
1214
|
+
* dropdownContainers: [".n-base-select-menu"],
|
|
1215
|
+
* dropdownOptions: [".n-base-select-option"],
|
|
1216
|
+
* };
|
|
1217
|
+
* 追加到 UI_LIBRARIES 即可,弹窗顶层仲裁与下拉浮层发现自动获得该库精确层支持;
|
|
1218
|
+
* 未注册的库仍由通用子串层兜底。配套要求:补一个该库真页面的 e2e
|
|
1219
|
+
* (playground 模式照抄 test:enterprise),防止想当然的类名污染仲裁。
|
|
1220
|
+
*/
|
|
1221
|
+
const UI_LIBRARIES = [{
|
|
1222
|
+
name: "antd",
|
|
1223
|
+
modalContainers: [".ant-modal", ".ant-modal-content"],
|
|
1224
|
+
drawerContainers: [".ant-drawer", ".ant-drawer-content-wrapper"],
|
|
1225
|
+
dropdownContainers: [".ant-select-dropdown"],
|
|
1226
|
+
dropdownOptions: [".ant-select-item-option"]
|
|
1227
|
+
}, {
|
|
1228
|
+
name: "element-plus",
|
|
1229
|
+
modalContainers: [".el-dialog"],
|
|
1230
|
+
drawerContainers: [".el-drawer"],
|
|
1231
|
+
dropdownContainers: [".el-select-dropdown"],
|
|
1232
|
+
dropdownOptions: [".el-select-dropdown__item"]
|
|
1233
|
+
}];
|
|
1234
|
+
const flatOf = (libs, key) => libs.flatMap((lib) => lib[key]);
|
|
1235
|
+
/** 弹窗/抽屉容器(库精确层原料,未加 display:none 预过滤)。 */
|
|
1236
|
+
function dialogContainersOf(libs = UI_LIBRARIES) {
|
|
1237
|
+
return [...flatOf(libs, "modalContainers"), ...flatOf(libs, "drawerContainers")];
|
|
1238
|
+
}
|
|
1239
|
+
/** 弹窗模糊层选择器串:子串词 + 子节点排除(:not 双命名)+ display:none 预过滤。 */
|
|
1240
|
+
function composeDialogFuzzy(words = GENERIC.dialogWords, excludedParts = GENERIC.dialogExcludedParts) {
|
|
1241
|
+
return words.map((word) => [`[class*='${word}']:not([style*='display: none'])`, ...excludedParts.map((part) => `:not([class*='${word}-${part}']):not([class*='${word}__${part}'])`)].join("")).join(", ");
|
|
1242
|
+
}
|
|
1243
|
+
/** 下拉浮层容器选择器列表(库精确层 + 通用子串层,均带 display:none 预过滤)。 */
|
|
1244
|
+
function dropdownFloatSelectors(libs = UI_LIBRARIES) {
|
|
1245
|
+
return [...flatOf(libs, "dropdownContainers"), ...GENERIC.dropdownFloatWords.map((w) => `[class*='${w}']`)].map((sel) => `${sel}:not([style*='display: none']):not([class*='hidden'])`);
|
|
1246
|
+
}
|
|
1247
|
+
/** 下拉选项行选择器列表(标准层 role=option 由消费方自带 + 库精确层 + 通用子串层)。 */
|
|
1248
|
+
function dropdownOptionRowsOf(libs = UI_LIBRARIES) {
|
|
1249
|
+
return [...flatOf(libs, "dropdownOptions").map((sel) => `${sel}:not([class*='empty'])`), GENERIC.dropdownOptionRows];
|
|
1250
|
+
}
|
|
1251
|
+
/** 预组合聚合常量(消费方直接 import)。 */
|
|
1252
|
+
const LIBRARY_DIALOG_CONTAINERS = dialogContainersOf();
|
|
1253
|
+
const LIBRARY_DROPDOWN_CONTAINERS = dropdownFloatSelectors();
|
|
1254
|
+
const LIBRARY_DROPDOWN_OPTIONS = dropdownOptionRowsOf();
|
|
1255
|
+
//#endregion
|
|
1072
1256
|
//#region lib/types/browser/tools/common.js
|
|
1073
|
-
/**
|
|
1074
|
-
const DROPDOWN_OPTION_SELECTOR =
|
|
1075
|
-
"[role='option']",
|
|
1076
|
-
"[role='menuitem']",
|
|
1077
|
-
"[class*='select'][class*='dropdown'] [class*='option']",
|
|
1078
|
-
"[class*='select'][class*='dropdown'] [class*='item']",
|
|
1079
|
-
"[role='listbox'] [role='option']"
|
|
1080
|
-
].join(", ");
|
|
1257
|
+
/** 组件库下拉框的可见选项枚举(ui-library 注册表单一来源,值与抽取前等价)。 */
|
|
1258
|
+
const DROPDOWN_OPTION_SELECTOR = GENERIC.dropdownOptionEnumeration;
|
|
1081
1259
|
/** 读取 PNG 文件头(IHDR)中的宽高,不解码像素(导出供测试)。 */
|
|
1082
1260
|
function pngSize(buf) {
|
|
1083
1261
|
if (buf.length < 24 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG file");
|
|
@@ -1212,10 +1390,216 @@ function workspaceFilePath(cwd, file) {
|
|
|
1212
1390
|
if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
|
|
1213
1391
|
return p;
|
|
1214
1392
|
}
|
|
1393
|
+
const AsyncFunctionConstructor = Object.getPrototypeOf(async function() {}).constructor;
|
|
1394
|
+
/** 编译校验(只编译不执行):语法合法返回 true。 */
|
|
1395
|
+
function compiles(body) {
|
|
1396
|
+
try {
|
|
1397
|
+
new AsyncFunctionConstructor(body);
|
|
1398
|
+
return true;
|
|
1399
|
+
} catch {
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
/** `/` 出现在这些字符(或关键字)之后按正则字面量处理,否则按除号。 */
|
|
1404
|
+
const REGEX_ALLOWED_AFTER_CHARS = /* @__PURE__ */ new Set("([{,;=:?&|!%^~*+<>-/");
|
|
1405
|
+
const REGEX_ALLOWED_AFTER_WORDS = /* @__PURE__ */ new Set([
|
|
1406
|
+
"return",
|
|
1407
|
+
"typeof",
|
|
1408
|
+
"instanceof",
|
|
1409
|
+
"in",
|
|
1410
|
+
"of",
|
|
1411
|
+
"new",
|
|
1412
|
+
"delete",
|
|
1413
|
+
"void",
|
|
1414
|
+
"throw",
|
|
1415
|
+
"case",
|
|
1416
|
+
"do",
|
|
1417
|
+
"else",
|
|
1418
|
+
"yield",
|
|
1419
|
+
"await"
|
|
1420
|
+
]);
|
|
1421
|
+
/**
|
|
1422
|
+
* 词法扫描(为 ② 服务,fail-safe):定位最后一个「顶层显式 ;」的结束位置,
|
|
1423
|
+
* 并检测顶层 return 关键字(有则说明调用方自己管理返回,不自动改写)。
|
|
1424
|
+
* 顶层 = 不在字符串/模板/注释/正则内,且 ()[]{} 合并深度为 0(含模板 ${} 插值内)。
|
|
1425
|
+
*/
|
|
1426
|
+
function scanTopLevel(script) {
|
|
1427
|
+
let lastSemicolonEnd = null;
|
|
1428
|
+
let topLevelReturn = false;
|
|
1429
|
+
let state = "code";
|
|
1430
|
+
let depth = 0;
|
|
1431
|
+
const tplDepths = [];
|
|
1432
|
+
let prevSig = "";
|
|
1433
|
+
let prevWord = "";
|
|
1434
|
+
const isWordChar = (c) => /[A-Za-z0-9_$]/.test(c);
|
|
1435
|
+
for (let i = 0; i < script.length; i++) {
|
|
1436
|
+
const ch = script[i];
|
|
1437
|
+
switch (state) {
|
|
1438
|
+
case "line":
|
|
1439
|
+
if (ch === "\n") state = "code";
|
|
1440
|
+
continue;
|
|
1441
|
+
case "block":
|
|
1442
|
+
if (ch === "*" && script[i + 1] === "/") {
|
|
1443
|
+
state = "code";
|
|
1444
|
+
i++;
|
|
1445
|
+
}
|
|
1446
|
+
continue;
|
|
1447
|
+
case "sq":
|
|
1448
|
+
case "dq":
|
|
1449
|
+
if (ch === "\\") i++;
|
|
1450
|
+
else if (state === "sq" && ch === "'" || state === "dq" && ch === "\"") state = "code";
|
|
1451
|
+
continue;
|
|
1452
|
+
case "tpl":
|
|
1453
|
+
if (ch === "\\") i++;
|
|
1454
|
+
else if (ch === "`") {
|
|
1455
|
+
state = "code";
|
|
1456
|
+
prevSig = "`";
|
|
1457
|
+
prevWord = "";
|
|
1458
|
+
} else if (ch === "$" && script[i + 1] === "{") {
|
|
1459
|
+
tplDepths.push(depth);
|
|
1460
|
+
state = "code";
|
|
1461
|
+
i++;
|
|
1462
|
+
}
|
|
1463
|
+
continue;
|
|
1464
|
+
case "regex":
|
|
1465
|
+
if (ch === "\\") i++;
|
|
1466
|
+
else if (ch === "[") state = "regexClass";
|
|
1467
|
+
else if (ch === "/") {
|
|
1468
|
+
state = "code";
|
|
1469
|
+
prevSig = "/";
|
|
1470
|
+
prevWord = "";
|
|
1471
|
+
}
|
|
1472
|
+
continue;
|
|
1473
|
+
case "regexClass":
|
|
1474
|
+
if (ch === "\\") i++;
|
|
1475
|
+
else if (ch === "]") state = "regex";
|
|
1476
|
+
continue;
|
|
1477
|
+
default:
|
|
1478
|
+
if (ch === "/" && script[i + 1] === "/") {
|
|
1479
|
+
state = "line";
|
|
1480
|
+
i++;
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
if (ch === "/" && script[i + 1] === "*") {
|
|
1484
|
+
state = "block";
|
|
1485
|
+
i++;
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (ch === "/") {
|
|
1489
|
+
if (prevSig === "" || REGEX_ALLOWED_AFTER_CHARS.has(prevSig) || REGEX_ALLOWED_AFTER_WORDS.has(prevWord)) state = "regex";
|
|
1490
|
+
else {
|
|
1491
|
+
prevSig = "/";
|
|
1492
|
+
prevWord = "";
|
|
1493
|
+
}
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
if (ch === "'" || ch === "\"") {
|
|
1497
|
+
state = ch === "'" ? "sq" : "dq";
|
|
1498
|
+
prevSig = ch;
|
|
1499
|
+
prevWord = "";
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
if (ch === "`") {
|
|
1503
|
+
state = "tpl";
|
|
1504
|
+
prevSig = ch;
|
|
1505
|
+
prevWord = "";
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
1509
|
+
depth++;
|
|
1510
|
+
prevSig = ch;
|
|
1511
|
+
prevWord = "";
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
if (ch === ")" || ch === "]") {
|
|
1515
|
+
depth = Math.max(0, depth - 1);
|
|
1516
|
+
prevSig = ch;
|
|
1517
|
+
prevWord = "";
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
if (ch === "}") {
|
|
1521
|
+
if (tplDepths.length > 0 && depth === tplDepths[tplDepths.length - 1]) {
|
|
1522
|
+
tplDepths.pop();
|
|
1523
|
+
state = "tpl";
|
|
1524
|
+
} else depth = Math.max(0, depth - 1);
|
|
1525
|
+
prevSig = ch;
|
|
1526
|
+
prevWord = "";
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
if (ch === ";") {
|
|
1530
|
+
if (depth === 0 && tplDepths.length === 0) lastSemicolonEnd = i + 1;
|
|
1531
|
+
prevSig = ch;
|
|
1532
|
+
prevWord = "";
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (isWordChar(ch)) {
|
|
1536
|
+
let j = i;
|
|
1537
|
+
while (j < script.length && isWordChar(script[j])) j++;
|
|
1538
|
+
const word = script.slice(i, j);
|
|
1539
|
+
if (depth === 0 && tplDepths.length === 0 && word === "return" && prevSig !== "." && !isWordChar(prevSig)) topLevelReturn = true;
|
|
1540
|
+
prevWord = word;
|
|
1541
|
+
prevSig = script[j - 1];
|
|
1542
|
+
i = j - 1;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
if (!/\s/.test(ch)) {
|
|
1546
|
+
prevSig = ch;
|
|
1547
|
+
prevWord = "";
|
|
1548
|
+
}
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return {
|
|
1553
|
+
lastSemicolonEnd,
|
|
1554
|
+
topLevelReturn
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* 把 eval 脚本包装成可直接交给 page.evaluate 的代码串,并标明实际执行形态。
|
|
1559
|
+
* 语法非法时在 Node 侧抛出引擎原始错误(消息与页内 V8 同源,提前暴露)。
|
|
1560
|
+
*/
|
|
1561
|
+
function buildEvalScript(script) {
|
|
1562
|
+
if (compiles(`return (\n${script}\n)`)) return {
|
|
1563
|
+
code: `(async () => (\n${script}\n))()`,
|
|
1564
|
+
mode: "expression"
|
|
1565
|
+
};
|
|
1566
|
+
try {
|
|
1567
|
+
new AsyncFunctionConstructor(script);
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
throw new Error(`eval 脚本语法错误: ${err.message}`);
|
|
1570
|
+
}
|
|
1571
|
+
const { lastSemicolonEnd, topLevelReturn } = scanTopLevel(script);
|
|
1572
|
+
if (!topLevelReturn && lastSemicolonEnd !== null) {
|
|
1573
|
+
const head = script.slice(0, lastSemicolonEnd);
|
|
1574
|
+
const tail = script.slice(lastSemicolonEnd);
|
|
1575
|
+
if (tail.trim() !== "") {
|
|
1576
|
+
const candidate = `${head}\nreturn (\n${tail}\n);`;
|
|
1577
|
+
if (compiles(candidate)) return {
|
|
1578
|
+
code: `(async () => {\n${candidate}\n})()`,
|
|
1579
|
+
mode: "statements-auto-return"
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return {
|
|
1584
|
+
code: `(async () => {\n${script}\n})()`,
|
|
1585
|
+
mode: "statements"
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1215
1588
|
/**
|
|
1216
1589
|
* 规则化抽取:fields 定义抽取结构,next 选择器驱动翻页(maxPages 上限),
|
|
1217
1590
|
* 行数超 maxRows 截断;format/file 控制序列化与落盘(json/csv/jsonl/markdown)。
|
|
1218
1591
|
*/
|
|
1592
|
+
async function assertExpectedUrl(page, expectUrl, action) {
|
|
1593
|
+
if (typeof expectUrl !== "string" || expectUrl === "") return;
|
|
1594
|
+
const current = page.url();
|
|
1595
|
+
let hit;
|
|
1596
|
+
try {
|
|
1597
|
+
hit = new RegExp(expectUrl).test(current);
|
|
1598
|
+
} catch {
|
|
1599
|
+
hit = current.includes(expectUrl);
|
|
1600
|
+
}
|
|
1601
|
+
if (!hit) throw new Error(`${action} 前置校验失败: expectUrl "${expectUrl}" 与当前页面不符(当前: ${current})——页面可能被切换/回退,请先 browser_snapshot 确认后再操作`);
|
|
1602
|
+
}
|
|
1219
1603
|
//#endregion
|
|
1220
1604
|
//#region lib/types/browser/primitives/dom.js
|
|
1221
1605
|
/**
|
|
@@ -1233,9 +1617,9 @@ function extractElementLabel(el) {
|
|
|
1233
1617
|
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)).filter((n) => !!n).map((n) => (n.innerText || n.textContent || "").trim()).filter(Boolean).join(" ");
|
|
1234
1618
|
if (text) return text;
|
|
1235
1619
|
}
|
|
1236
|
-
const formItem = el.closest(
|
|
1620
|
+
const formItem = el.closest(GENERIC.formItemContainers);
|
|
1237
1621
|
if (formItem) {
|
|
1238
|
-
const labelEl = formItem.querySelector(
|
|
1622
|
+
const labelEl = formItem.querySelector(GENERIC.formItemLabels);
|
|
1239
1623
|
if (labelEl) {
|
|
1240
1624
|
const txt = (labelEl.innerText || labelEl.textContent || "").trim();
|
|
1241
1625
|
if (txt) return txt;
|
|
@@ -1430,29 +1814,63 @@ async function endChangeWatch(page, options = {}) {
|
|
|
1430
1814
|
}
|
|
1431
1815
|
//#endregion
|
|
1432
1816
|
//#region lib/types/browser/primitives/dialog.js
|
|
1817
|
+
/** 标准层:WAI-ARIA 语义,任何库与原生实现通用。 */
|
|
1818
|
+
const ARIA_DIALOG_SELECTORS = [
|
|
1819
|
+
"[role='dialog']",
|
|
1820
|
+
"[role='alertdialog']",
|
|
1821
|
+
"[aria-modal='true']",
|
|
1822
|
+
"dialog[open]"
|
|
1823
|
+
];
|
|
1824
|
+
/** display:none 内联样式预过滤(动画关闭中的弹窗/keep-alive 残留)。 */
|
|
1825
|
+
const hiddenGuard = (sel) => `${sel}:not([style*='display: none'])`;
|
|
1826
|
+
/** 精确层 = 标准层 + 库精确层(ui-library 注册表单一来源,新增 UI 库 = 追加 spec)。 */
|
|
1827
|
+
const DIALOG_PRECISE_SELECTORS = [...ARIA_DIALOG_SELECTORS, ...LIBRARY_DIALOG_CONTAINERS].map(hiddenGuard).join(", ");
|
|
1828
|
+
/** 通用模糊层(未注册库兜底):子串词 + :not() 排除子节点。 */
|
|
1829
|
+
const DIALOG_FUZZY_SELECTORS = composeDialogFuzzy();
|
|
1433
1830
|
/**
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1831
|
+
* 历史并集 = 精确层 + 模糊层。semantic.ts 弹窗上下文检测与 getActiveDialog 的
|
|
1832
|
+
* domOrder 兜底消费(行为与抽取前的并集等价)。
|
|
1436
1833
|
*/
|
|
1437
|
-
const ACTIVE_DIALOG_SELECTORS = [
|
|
1438
|
-
"[role='dialog']:not([style*='display: none'])",
|
|
1439
|
-
"[role='alertdialog']:not([style*='display: none'])",
|
|
1440
|
-
"[aria-modal='true']:not([style*='display: none'])",
|
|
1441
|
-
"dialog[open]",
|
|
1442
|
-
".ant-modal:not([style*='display: none'])",
|
|
1443
|
-
".el-dialog:not([style*='display: none'])",
|
|
1444
|
-
".ant-drawer:not([style*='display: none'])",
|
|
1445
|
-
".el-drawer:not([style*='display: none'])",
|
|
1446
|
-
"[class*='modal']:not([style*='display: none'])",
|
|
1447
|
-
"[class*='drawer']:not([style*='display: none'])",
|
|
1448
|
-
"[class*='dialog']:not([style*='display: none'])"
|
|
1449
|
-
].join(", ");
|
|
1834
|
+
const ACTIVE_DIALOG_SELECTORS = [DIALOG_PRECISE_SELECTORS, DIALOG_FUZZY_SELECTORS].join(", ");
|
|
1450
1835
|
/**
|
|
1451
|
-
*
|
|
1836
|
+
* 获取当前页面上处于活动状态的最顶层弹窗容器;无弹窗返回 null。
|
|
1837
|
+
* ① 快速通道——并集零命中(绝大多数无弹窗的工具调用)直接返回 null,与旧行为
|
|
1838
|
+
* 「空 locator + 下游 count 0」等价,零额外 evaluate 往返;
|
|
1839
|
+
* ② 有弹窗时走分层选择器 + 页内 z-index/可见性仲裁(walker pickTopDialog,
|
|
1840
|
+
* 页内逻辑单一来源),单次 round-trip;
|
|
1841
|
+
* ③ evaluate 失败(极端 CSP/销毁竞态)或复核计数为 0 时回退文档序 last()
|
|
1842
|
+
* 旧行为并标 via: "domOrder",不阻断主流程。
|
|
1452
1843
|
*/
|
|
1453
|
-
function getActiveDialog(page) {
|
|
1844
|
+
async function getActiveDialog(page) {
|
|
1845
|
+
try {
|
|
1846
|
+
const unionLoc = page.locator(ACTIVE_DIALOG_SELECTORS);
|
|
1847
|
+
if (typeof unionLoc.count === "function") {
|
|
1848
|
+
if (await withTimeout(unionLoc.count(), 800, "弹窗快速探测").catch(() => 0) === 0) return null;
|
|
1849
|
+
}
|
|
1850
|
+
} catch {}
|
|
1851
|
+
try {
|
|
1852
|
+
const pick = await withTimeout(page.evaluate(pickTopDialog, {
|
|
1853
|
+
precise: DIALOG_PRECISE_SELECTORS,
|
|
1854
|
+
fuzzy: DIALOG_FUZZY_SELECTORS
|
|
1855
|
+
}), 1500, "活动弹窗仲裁");
|
|
1856
|
+
if (pick !== null && typeof pick === "object" && typeof pick.selector === "string" && pick.selector !== "") {
|
|
1857
|
+
const hit = pick;
|
|
1858
|
+
const loc = page.locator(hit.selector);
|
|
1859
|
+
if (typeof loc.count === "function") {
|
|
1860
|
+
if (await withTimeout(loc.count(), 800, "活动弹窗复核").catch(() => 0) > 0) return {
|
|
1861
|
+
locator: typeof loc.first === "function" ? loc.first() : loc,
|
|
1862
|
+
via: "zIndex",
|
|
1863
|
+
z: hit.z,
|
|
1864
|
+
tier: hit.tier
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
} catch {}
|
|
1454
1869
|
const dialogLoc = page.locator(ACTIVE_DIALOG_SELECTORS);
|
|
1455
|
-
return
|
|
1870
|
+
return {
|
|
1871
|
+
locator: typeof dialogLoc.last === "function" ? dialogLoc.last() : dialogLoc.first(),
|
|
1872
|
+
via: "domOrder"
|
|
1873
|
+
};
|
|
1456
1874
|
}
|
|
1457
1875
|
//#endregion
|
|
1458
1876
|
//#region lib/types/browser/primitives/select.js
|
|
@@ -1471,14 +1889,14 @@ async function executeUniversalSelect(ctx) {
|
|
|
1471
1889
|
throw new Error(`原生下拉无选项 "${want}"; 可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
|
|
1472
1890
|
}
|
|
1473
1891
|
let resolvedTarget = target;
|
|
1474
|
-
if (await withTimeout(target.evaluate((el) => {
|
|
1892
|
+
if (await withTimeout(target.evaluate((el, hosts) => {
|
|
1475
1893
|
if (el.tagName.toLowerCase() === "label") return true;
|
|
1476
1894
|
const cls = typeof el.className === "string" ? el.className : "";
|
|
1477
1895
|
if (/(?:form-item|form__item|form-group)/i.test(cls)) return true;
|
|
1478
|
-
if (el.closest(
|
|
1896
|
+
if (el.closest(hosts)) return !el.matches("[role='combobox'], select, [class*='select-selector']");
|
|
1479
1897
|
if (!el.matches("[role='combobox'], select, [class*='select'], [class*='selector']")) return true;
|
|
1480
1898
|
return false;
|
|
1481
|
-
}), 1500, "探测 label 或非标文本容器").catch(() => false)) {
|
|
1899
|
+
}, GENERIC.formItemHosts), 1500, "探测 label 或非标文本容器").catch(() => false)) {
|
|
1482
1900
|
const descendantSelect = target.locator("xpath=.//*[@role='combobox' or self::select or contains(@class, 'select-selector') or contains(@class, 'select')][not(self::label)]").first();
|
|
1483
1901
|
if (await descendantSelect.count().catch(() => 0) > 0) resolvedTarget = descendantSelect;
|
|
1484
1902
|
else {
|
|
@@ -1503,15 +1921,15 @@ async function executeUniversalSelect(ctx) {
|
|
|
1503
1921
|
const hostLocator = resolvedTarget.locator("xpath=ancestor-or-self::*[@role='combobox' or contains(@class, 'select') or contains(@class, 'selector')][last()]");
|
|
1504
1922
|
const actualTarget = await hostLocator.count().catch(() => 0) > 0 ? hostLocator.first() : resolvedTarget;
|
|
1505
1923
|
if (clear) try {
|
|
1506
|
-
const clearBtn = actualTarget.locator(
|
|
1924
|
+
const clearBtn = actualTarget.locator(GENERIC.dropdownClearButtons).first();
|
|
1507
1925
|
if (await clearBtn.count().catch(() => 0) > 0 && await clearBtn.isVisible().catch(() => false)) await clearBtn.click({ timeout: 1e3 }).catch(() => {});
|
|
1508
1926
|
else {
|
|
1509
|
-
const removeBtns = actualTarget.locator(
|
|
1927
|
+
const removeBtns = actualTarget.locator(GENERIC.dropdownRemoveButtons);
|
|
1510
1928
|
const removeCount = await removeBtns.count().catch(() => 0);
|
|
1511
1929
|
for (let i = 0; i < removeCount; i++) await removeBtns.nth(0).click({ timeout: 500 }).catch(() => {});
|
|
1512
1930
|
}
|
|
1513
1931
|
} catch {}
|
|
1514
|
-
|
|
1932
|
+
const probeExpanded = () => withTimeout(actualTarget.evaluate((el) => {
|
|
1515
1933
|
if (el.getAttribute("aria-expanded") === "true") return true;
|
|
1516
1934
|
const host = el.closest("[role='combobox'], [class*='select']") || el;
|
|
1517
1935
|
if (host && host.getAttribute("aria-expanded") === "true") return true;
|
|
@@ -1524,8 +1942,11 @@ async function executeUniversalSelect(ctx) {
|
|
|
1524
1942
|
if (dd && dd.offsetParent !== null) return true;
|
|
1525
1943
|
}
|
|
1526
1944
|
return false;
|
|
1527
|
-
}), 2e3, "检查下拉展开状态").catch(() => false)
|
|
1528
|
-
|
|
1945
|
+
}), 2e3, "检查下拉展开状态").catch(() => false);
|
|
1946
|
+
const isExpanded = await probeExpanded();
|
|
1947
|
+
let expandSignalMissed = false;
|
|
1948
|
+
if (!isExpanded) {
|
|
1949
|
+
const triggerChild = actualTarget.locator(GENERIC.dropdownTriggerParts).first();
|
|
1529
1950
|
let clicked = false;
|
|
1530
1951
|
if (await triggerChild.count().catch(() => 0) > 0) {
|
|
1531
1952
|
await triggerChild.click({ timeout: 2e3 }).catch(async () => {
|
|
@@ -1543,7 +1964,9 @@ async function executeUniversalSelect(ctx) {
|
|
|
1543
1964
|
});
|
|
1544
1965
|
});
|
|
1545
1966
|
await page.waitForTimeout(120);
|
|
1967
|
+
expandSignalMissed = !await probeExpanded();
|
|
1546
1968
|
}
|
|
1969
|
+
const optionWaitMs = expandSignalMissed ? 1500 : Math.min(timeout, 5e3);
|
|
1547
1970
|
const controlsId = await withTimeout(actualTarget.evaluate((el) => {
|
|
1548
1971
|
const direct = el.getAttribute("aria-controls") || el.getAttribute("aria-owns");
|
|
1549
1972
|
if (direct) return direct;
|
|
@@ -1551,8 +1974,8 @@ async function executeUniversalSelect(ctx) {
|
|
|
1551
1974
|
const inner = host?.querySelector("input[role='combobox'], [aria-controls], [aria-owns]");
|
|
1552
1975
|
return inner?.getAttribute("aria-controls") || inner?.getAttribute("aria-owns") || host?.getAttribute("aria-controls") || "";
|
|
1553
1976
|
}), 2e3, "解析下拉关联 controlsId").catch(() => "");
|
|
1554
|
-
const optionSelector = "[role='option']:not([style*='height: 0px']):not([class*='empty']),
|
|
1555
|
-
const dropdownSelector = "[role='listbox']:not([style*='display: none']),
|
|
1977
|
+
const optionSelector = ["[role='option']:not([style*='height: 0px']):not([class*='empty'])", ...LIBRARY_DROPDOWN_OPTIONS].join(", ");
|
|
1978
|
+
const dropdownSelector = ["[role='listbox']:not([style*='display: none'])", ...LIBRARY_DROPDOWN_CONTAINERS].join(", ");
|
|
1556
1979
|
let options;
|
|
1557
1980
|
let targetDropdown = null;
|
|
1558
1981
|
if (controlsId) {
|
|
@@ -1570,7 +1993,7 @@ async function executeUniversalSelect(ctx) {
|
|
|
1570
1993
|
}
|
|
1571
1994
|
await options.first().waitFor({
|
|
1572
1995
|
state: "visible",
|
|
1573
|
-
timeout:
|
|
1996
|
+
timeout: optionWaitMs
|
|
1574
1997
|
}).catch(() => {});
|
|
1575
1998
|
const visibleTexts = (await options.evaluateAll((els) => els.map((el) => {
|
|
1576
1999
|
return {
|
|
@@ -1580,10 +2003,12 @@ async function executeUniversalSelect(ctx) {
|
|
|
1580
2003
|
};
|
|
1581
2004
|
})).catch(() => [])).map((e) => e.text || e.title || e.aria).filter(Boolean);
|
|
1582
2005
|
if (visibleTexts.length === 0) {
|
|
1583
|
-
const
|
|
2006
|
+
const emptySelector = GENERIC.emptyStates;
|
|
2007
|
+
const isEmpty = targetDropdown !== null ? await targetDropdown.locator(emptySelector).first().isVisible().catch(() => false) : false;
|
|
1584
2008
|
await actualTarget.click({ timeout: 1e3 }).catch(() => {});
|
|
1585
|
-
|
|
1586
|
-
throw new Error(
|
|
2009
|
+
const attribution = expandSignalMissed ? ";触发器点击疑似未生效(展开信号缺失,可能被透明遮罩拦截,建议 browser_snapshot 核实或配合 click effect 验证)" : "";
|
|
2010
|
+
if (isEmpty) throw new Error(`下拉浮层当前无选项(数据为空态/无可选数据): ${label}${attribution}`);
|
|
2011
|
+
throw new Error(`点击 ${label} 后未出现下拉选项(选项列表为空态)${attribution}`);
|
|
1587
2012
|
}
|
|
1588
2013
|
const wantStripped = want.replace(/\s+/g, "");
|
|
1589
2014
|
let index = visibleTexts.findIndex((t) => t === want);
|
|
@@ -1729,7 +2154,7 @@ async function fillFormFieldHelper(page, field, scope) {
|
|
|
1729
2154
|
const root = scope ? page.locator(scope) : page;
|
|
1730
2155
|
const { key, value } = field;
|
|
1731
2156
|
try {
|
|
1732
|
-
const formItems = root.locator(
|
|
2157
|
+
const formItems = root.locator(GENERIC.formItemContainers);
|
|
1733
2158
|
const count = await formItems.count().catch(() => 0);
|
|
1734
2159
|
for (let i = 0; i < count; i++) {
|
|
1735
2160
|
const item = formItems.nth(i);
|
|
@@ -1791,6 +2216,45 @@ async function fillFormFieldHelper(page, field, scope) {
|
|
|
1791
2216
|
};
|
|
1792
2217
|
}
|
|
1793
2218
|
//#endregion
|
|
2219
|
+
//#region lib/types/browser/primitives/drag.js
|
|
2220
|
+
/**
|
|
2221
|
+
* 核心:在两个已解析的 Locator 之间执行拖拽。
|
|
2222
|
+
* 优先 Playwright dragTo(覆盖 HTML5 DnD 与多数组件库);
|
|
2223
|
+
* 失败或调用方指定 preferMouse(如跨 Frame,dragTo 的已知限制)时
|
|
2224
|
+
* 降级为物理鼠标序列。返回实际使用的通道。失败抛错(带可定位的诊断信息)。
|
|
2225
|
+
*/
|
|
2226
|
+
async function dragAndDropLocators(page, source, target, opts = {}) {
|
|
2227
|
+
const timeoutMs = opts.timeoutMs ?? 3e3;
|
|
2228
|
+
if (!opts.preferMouse) try {
|
|
2229
|
+
if (typeof source.dragTo === "function") {
|
|
2230
|
+
await source.dragTo(target, { timeout: timeoutMs });
|
|
2231
|
+
return "dragTo";
|
|
2232
|
+
}
|
|
2233
|
+
} catch {}
|
|
2234
|
+
let srcErr = null;
|
|
2235
|
+
const srcBox = await source.boundingBox({ timeout: timeoutMs }).catch((e) => {
|
|
2236
|
+
srcErr = e;
|
|
2237
|
+
return null;
|
|
2238
|
+
});
|
|
2239
|
+
if (!srcBox) throw new Error(`drag 失败: 源元素不可见或未命中(等待 ${timeoutMs}ms)${srcErr instanceof Error ? `: ${srcErr.message}` : ""}`);
|
|
2240
|
+
let tgtErr = null;
|
|
2241
|
+
const tgtBox = await target.boundingBox({ timeout: timeoutMs }).catch((e) => {
|
|
2242
|
+
tgtErr = e;
|
|
2243
|
+
return null;
|
|
2244
|
+
});
|
|
2245
|
+
if (!tgtBox) throw new Error(`drag 失败: 目标元素不可见或未命中(等待 ${timeoutMs}ms)${tgtErr instanceof Error ? `: ${tgtErr.message}` : ""}`);
|
|
2246
|
+
const viewport = typeof page.viewportSize === "function" ? page.viewportSize() : null;
|
|
2247
|
+
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);
|
|
2248
|
+
if (outsideViewport && typeof target.scrollIntoViewIfNeeded === "function") await target.scrollIntoViewIfNeeded({ timeout: timeoutMs }).catch(() => {});
|
|
2249
|
+
const sx = (outsideViewport ? await source.boundingBox({ timeout: timeoutMs }).catch(() => null) : null) ?? srcBox;
|
|
2250
|
+
const tx = (outsideViewport ? await target.boundingBox({ timeout: timeoutMs }).catch(() => null) : null) ?? tgtBox;
|
|
2251
|
+
await page.mouse.move(sx.x + sx.width / 2, sx.y + sx.height / 2);
|
|
2252
|
+
await page.mouse.down();
|
|
2253
|
+
await page.mouse.move(tx.x + tx.width / 2, tx.y + tx.height / 2, { steps: 8 });
|
|
2254
|
+
await page.mouse.up();
|
|
2255
|
+
return "mouse";
|
|
2256
|
+
}
|
|
2257
|
+
//#endregion
|
|
1794
2258
|
//#region lib/types/browser/session/semantic.js
|
|
1795
2259
|
/**
|
|
1796
2260
|
* 页内语义扫描:按 query 打分收集可交互元素(弹窗内优先、可见优先、高分优先)。
|
|
@@ -2149,6 +2613,37 @@ const failureContextProbe = injectWalker(() => {
|
|
|
2149
2613
|
hasShadow
|
|
2150
2614
|
};
|
|
2151
2615
|
});
|
|
2616
|
+
/**
|
|
2617
|
+
* selector 零命中时的相似属性探针(best-effort):提取首个属性片段(或 #id)
|
|
2618
|
+
* 做包含匹配计数——直接暴露「锚点属性全失效」与「组合选择器其他部分写错」两种病因。
|
|
2619
|
+
* 纯提示用途,失败/无属性片段返回空串。
|
|
2620
|
+
*/
|
|
2621
|
+
const similarAttrProbe = injectWalker((rawSelector) => {
|
|
2622
|
+
let attr = "";
|
|
2623
|
+
let val = "";
|
|
2624
|
+
const m = rawSelector.match(/\[([\w-]+)(?:\^|\$|\*|~)?=['"]([^'"]+)['"]\]/);
|
|
2625
|
+
if (m !== null) {
|
|
2626
|
+
attr = m[1] ?? "";
|
|
2627
|
+
val = m[2] ?? "";
|
|
2628
|
+
} else {
|
|
2629
|
+
const idm = rawSelector.match(/^#([\w-]+)/);
|
|
2630
|
+
if (idm !== null) {
|
|
2631
|
+
attr = "id";
|
|
2632
|
+
val = idm[1] ?? "";
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
if (attr === "" || val === "") return "";
|
|
2636
|
+
try {
|
|
2637
|
+
const safe = val.replace(/["\\]/g, "");
|
|
2638
|
+
if (safe === "") return "";
|
|
2639
|
+
const hits = Array.from(document.querySelectorAll(`[${attr}*="${safe}"]`)).slice(0, 50);
|
|
2640
|
+
if (hits.length === 0) return `页面不存在任何 ${attr} 含 "${safe}" 的元素(锚点属性可能已失效或页面已切换)`;
|
|
2641
|
+
const samples = hits.slice(0, 3).map((el) => el.getAttribute(attr) || "").filter((s) => s !== "").join(" | ").slice(0, 120);
|
|
2642
|
+
return `页面存在 ${hits.length} 个 ${attr} 含 "${safe}" 的元素(组合选择器的其他部分可能写错)${samples !== "" ? `,如: ${samples}` : ""}`;
|
|
2643
|
+
} catch {
|
|
2644
|
+
return "";
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2152
2647
|
/** ref 坐标兜底的名称相似判定:去空白互相包含,或 LCS 占比 ≥ 0.6。 */
|
|
2153
2648
|
function nameSimilar(a, b) {
|
|
2154
2649
|
if (a === "" || b === "") return true;
|
|
@@ -2230,9 +2725,9 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2230
2725
|
};
|
|
2231
2726
|
}
|
|
2232
2727
|
if (!isIFrame) try {
|
|
2233
|
-
const
|
|
2234
|
-
if (
|
|
2235
|
-
const inDialog = entry.name !== "" ?
|
|
2728
|
+
const dlg = (await getActiveDialog(page))?.locator;
|
|
2729
|
+
if (dlg && typeof dlg.getByRole === "function") {
|
|
2730
|
+
const inDialog = entry.name !== "" ? dlg.getByRole(entry.role, { name: buildFlexibleTextPattern(entry.name) }).first() : dlg.getByRole(entry.role).first();
|
|
2236
2731
|
if (await withTimeout(inDialog.count(), 1e3, "弹窗内 role 查找").catch(() => 0) > 0) return {
|
|
2237
2732
|
locator: inDialog,
|
|
2238
2733
|
container,
|
|
@@ -2354,10 +2849,29 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2354
2849
|
}
|
|
2355
2850
|
const what = args.what;
|
|
2356
2851
|
if (typeof what === "string" && what.trim() !== "" && args.selector === void 0 && args.ref === void 0 && args.mark === void 0 && rowPattern === void 0) {
|
|
2852
|
+
const navSeqBefore = pageNavSeq(page);
|
|
2853
|
+
let urlBefore = null;
|
|
2854
|
+
try {
|
|
2855
|
+
urlBefore = page.url();
|
|
2856
|
+
} catch {}
|
|
2357
2857
|
const res = await resolveSemanticTarget(page, what, {
|
|
2358
2858
|
scope: typeof args.scope === "string" && args.scope !== "" ? args.scope : void 0,
|
|
2359
2859
|
preferredRoles: opts.preferredRoles
|
|
2360
2860
|
});
|
|
2861
|
+
let urlAfter = urlBefore;
|
|
2862
|
+
try {
|
|
2863
|
+
urlAfter = page.url();
|
|
2864
|
+
} catch {}
|
|
2865
|
+
if (pageNavSeq(page) !== navSeqBefore || urlAfter !== urlBefore) {
|
|
2866
|
+
const raceErr = /* @__PURE__ */ new Error(`页面在语义定位过程中发生变化(${urlBefore} → ${page.url()}),本次扫描结果已作废;请重试或重新 browser_snapshot`);
|
|
2867
|
+
raceErr.locationFailure = {
|
|
2868
|
+
phase: "navRace",
|
|
2869
|
+
candidates: 0,
|
|
2870
|
+
dialogActive: false,
|
|
2871
|
+
hasShadow: false
|
|
2872
|
+
};
|
|
2873
|
+
throw raceErr;
|
|
2874
|
+
}
|
|
2361
2875
|
if (!("candidates" in res)) return res;
|
|
2362
2876
|
for (const role of [
|
|
2363
2877
|
"button",
|
|
@@ -2414,6 +2928,14 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2414
2928
|
...ctx
|
|
2415
2929
|
};
|
|
2416
2930
|
} catch {}
|
|
2931
|
+
const navDelta = navSinceStable(page);
|
|
2932
|
+
if (navDelta >= 1) {
|
|
2933
|
+
let route = "";
|
|
2934
|
+
try {
|
|
2935
|
+
route = page.url().slice(0, 80);
|
|
2936
|
+
} catch {}
|
|
2937
|
+
err.message = `${err.message};页面自上次成功操作后已发生 ${navDelta} 次导航,what 目标可能在另一页面${route !== "" ? `(当前: ${route})` : ""}`;
|
|
2938
|
+
}
|
|
2417
2939
|
throw err;
|
|
2418
2940
|
}
|
|
2419
2941
|
if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一 (或指定 row 与 action 进行表格行级定界,或用 what 语义定位)");
|
|
@@ -2431,9 +2953,9 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2431
2953
|
}
|
|
2432
2954
|
}
|
|
2433
2955
|
try {
|
|
2434
|
-
const
|
|
2435
|
-
if (
|
|
2436
|
-
const inDialog =
|
|
2956
|
+
const dlg = (await getActiveDialog(page))?.locator;
|
|
2957
|
+
if (dlg && typeof dlg.locator === "function") {
|
|
2958
|
+
const inDialog = dlg.locator(rawSelector);
|
|
2437
2959
|
if (typeof inDialog.count === "function") {
|
|
2438
2960
|
const inDialogCount = await withTimeout(inDialog.count(), 1e3, "弹窗内 selector 探测").catch(() => 0);
|
|
2439
2961
|
if (inDialogCount > 0) {
|
|
@@ -2454,7 +2976,43 @@ async function resolveTargetLocator(page, args, opts = {}) {
|
|
|
2454
2976
|
let visibleCount;
|
|
2455
2977
|
let target = typeof baseLocator.first === "function" ? baseLocator.first() : baseLocator;
|
|
2456
2978
|
if (typeof baseLocator.count === "function") {
|
|
2457
|
-
|
|
2979
|
+
try {
|
|
2980
|
+
totalCount = await withTimeout(baseLocator.count(), 2e3, "selector 计数");
|
|
2981
|
+
} catch {
|
|
2982
|
+
const err = /* @__PURE__ */ new Error(`selector 计数超时: ${rawSelector}(页面可能正忙或 DOM 过大,可重试;若持续超时建议 browser_snapshot 核实页面状态)`);
|
|
2983
|
+
err.locationFailure = {
|
|
2984
|
+
phase: "countTimeout",
|
|
2985
|
+
candidates: 0,
|
|
2986
|
+
dialogActive: false,
|
|
2987
|
+
hasShadow: false
|
|
2988
|
+
};
|
|
2989
|
+
throw err;
|
|
2990
|
+
}
|
|
2991
|
+
if (totalCount === 0) {
|
|
2992
|
+
const zeroMatchErr = /* @__PURE__ */ new Error(`selector 未命中: ${rawSelector}(元素未渲染/选择器写错/页面已切换;可用 browser_elements 探查表单结构、browser_wait_for 等元素出现,或 browser_snapshot 取 ref)`);
|
|
2993
|
+
zeroMatchErr.locationFailure = {
|
|
2994
|
+
phase: "zeroMatch",
|
|
2995
|
+
candidates: 0,
|
|
2996
|
+
dialogActive: false,
|
|
2997
|
+
hasShadow: false
|
|
2998
|
+
};
|
|
2999
|
+
try {
|
|
3000
|
+
const ctx = await withTimeout(page.evaluate(failureContextProbe, void 0), 1500, "失败特征探测");
|
|
3001
|
+
if (ctx !== null && typeof ctx === "object") zeroMatchErr.locationFailure = {
|
|
3002
|
+
phase: "zeroMatch",
|
|
3003
|
+
candidates: 0,
|
|
3004
|
+
...ctx
|
|
3005
|
+
};
|
|
3006
|
+
} catch {}
|
|
3007
|
+
try {
|
|
3008
|
+
const hint = await withTimeout(page.evaluate(similarAttrProbe, rawSelector), 1500, "相似属性探测");
|
|
3009
|
+
if (typeof hint === "string" && hint !== "") {
|
|
3010
|
+
zeroMatchErr.similarHint = hint;
|
|
3011
|
+
zeroMatchErr.message = `${zeroMatchErr.message};${hint}`;
|
|
3012
|
+
}
|
|
3013
|
+
} catch {}
|
|
3014
|
+
throw zeroMatchErr;
|
|
3015
|
+
}
|
|
2458
3016
|
if (totalCount > 0 && typeof baseLocator.locator === "function") {
|
|
2459
3017
|
const visibleLocator = baseLocator.locator("visible=true");
|
|
2460
3018
|
if (typeof visibleLocator.count === "function") {
|
|
@@ -2869,6 +3427,8 @@ const DOM_COMPAT_SCRIPT = `
|
|
|
2869
3427
|
};
|
|
2870
3428
|
})();
|
|
2871
3429
|
`;
|
|
3430
|
+
/** 最近一次工具触碰时间戳(getPage 打点):外部导航判定基准。 */
|
|
3431
|
+
let lastToolTouchAt = 0;
|
|
2872
3432
|
function attachPage(page, config) {
|
|
2873
3433
|
const timeoutMs = config.timeoutMs ?? 3e4;
|
|
2874
3434
|
page.setDefaultTimeout(timeoutMs);
|
|
@@ -2892,6 +3452,7 @@ function attachPage(page, config) {
|
|
|
2892
3452
|
const url = frame.url();
|
|
2893
3453
|
if (!url || url === "about:blank") return;
|
|
2894
3454
|
recordNav(profile, url);
|
|
3455
|
+
if (Date.now() - lastToolTouchAt > 1e4) noteExternalNav(page, url);
|
|
2895
3456
|
});
|
|
2896
3457
|
page.on("load", () => {
|
|
2897
3458
|
const list = historyOf(profile);
|
|
@@ -3117,6 +3678,7 @@ async function newPage(config) {
|
|
|
3117
3678
|
}
|
|
3118
3679
|
/** 返回当前活动页面(没有则新建),并保证浏览器已启动。 */
|
|
3119
3680
|
async function getPage(config) {
|
|
3681
|
+
lastToolTouchAt = Date.now();
|
|
3120
3682
|
const state = stateOf();
|
|
3121
3683
|
if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
|
|
3122
3684
|
pruneClosedPages(state);
|
|
@@ -3180,6 +3742,7 @@ async function closeBrowser() {
|
|
|
3180
3742
|
}
|
|
3181
3743
|
/** Best-effort current page identity, safe when nothing is open.title 有界防页面忙时挂起。 */
|
|
3182
3744
|
async function pageIdentity(page) {
|
|
3745
|
+
markStableNav(page);
|
|
3183
3746
|
try {
|
|
3184
3747
|
const title = await new Promise((resolve, reject) => {
|
|
3185
3748
|
const t = setTimeout(() => reject(/* @__PURE__ */ new Error("title timeout")), 2e3);
|
|
@@ -3775,15 +4338,20 @@ async function snapshotTool(config, args) {
|
|
|
3775
4338
|
const isDialogScope = /modal|dialog|drawer/i.test(scope);
|
|
3776
4339
|
let hint = isDialogScope ? "。目标容器可能已消失(弹窗/抽屉已关闭、已提交或页面已导航)" : "";
|
|
3777
4340
|
if (isDialogScope && typeof page.locator === "function") try {
|
|
3778
|
-
const activeDialogLoc = getActiveDialog(page);
|
|
3779
|
-
|
|
4341
|
+
const activeDialogLoc = (await getActiveDialog(page))?.locator;
|
|
4342
|
+
const count = activeDialogLoc !== void 0 && typeof activeDialogLoc.count === "function" ? await withTimeout(activeDialogLoc.count(), 1e3, "活动弹窗探测").catch(() => 0) : 0;
|
|
4343
|
+
if (activeDialogLoc !== void 0 && count > 0 && typeof activeDialogLoc.first === "function") {
|
|
3780
4344
|
const alternate = await withTimeout(activeDialogLoc.first().evaluate((el) => {
|
|
3781
|
-
|
|
4345
|
+
const out = [];
|
|
4346
|
+
const role = el.getAttribute("role");
|
|
4347
|
+
if (role === "dialog" || role === "alertdialog") out.push(`[role='${role}']`);
|
|
3782
4348
|
const cls = el.className;
|
|
3783
4349
|
if (typeof cls === "string" && cls.trim()) {
|
|
3784
|
-
const
|
|
3785
|
-
|
|
4350
|
+
const tokens = cls.trim().split(/\s+/);
|
|
4351
|
+
const relevant = tokens.find((c) => /modal|dialog|drawer/i.test(c)) || tokens[0];
|
|
4352
|
+
out.push(`.${relevant}`);
|
|
3786
4353
|
}
|
|
4354
|
+
if (out.length > 0) return out.join("");
|
|
3787
4355
|
return el.tagName.toLowerCase();
|
|
3788
4356
|
}), 1e3, "解析活动弹窗标识").catch(() => null);
|
|
3789
4357
|
if (alternate) hint += ` (检测到当前页面存在「${alternate}」容器,可能混淆了弹窗与抽屉类型)`;
|
|
@@ -4103,7 +4671,7 @@ async function somFallbackShot(config, exec) {
|
|
|
4103
4671
|
async function elementsTool(config, args) {
|
|
4104
4672
|
const page = await getPage(config);
|
|
4105
4673
|
const max = Math.min(Math.max(Number(args.max ?? 60) || 60, 1), 200);
|
|
4106
|
-
const data = await page.evaluate(([maxN, optionSelector]) => {
|
|
4674
|
+
const data = await page.evaluate(([maxN, optionSelector, formItemSel, formLabelSel]) => {
|
|
4107
4675
|
/** 提取元素的关联 label:aria-label → label[for] → form-item 标签(antd/element)。 */
|
|
4108
4676
|
function labelOf(el) {
|
|
4109
4677
|
const aria = el.getAttribute("aria-label");
|
|
@@ -4112,7 +4680,7 @@ async function elementsTool(config, args) {
|
|
|
4112
4680
|
const text = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
|
|
4113
4681
|
if (text) return text;
|
|
4114
4682
|
}
|
|
4115
|
-
const formLabel = el.closest(
|
|
4683
|
+
const formLabel = el.closest(formItemSel)?.querySelector(formLabelSel)?.textContent?.trim();
|
|
4116
4684
|
if (formLabel) return formLabel.replace(/[*::]\s*$/, "");
|
|
4117
4685
|
return "";
|
|
4118
4686
|
}
|
|
@@ -4144,7 +4712,12 @@ async function elementsTool(config, args) {
|
|
|
4144
4712
|
elements: out,
|
|
4145
4713
|
dropdownOptions: Array.from(document.querySelectorAll(optionSelector)).map((el) => (el.textContent ?? "").trim()).filter(Boolean).slice(0, 50)
|
|
4146
4714
|
};
|
|
4147
|
-
}, [
|
|
4715
|
+
}, [
|
|
4716
|
+
max,
|
|
4717
|
+
DROPDOWN_OPTION_SELECTOR,
|
|
4718
|
+
GENERIC.formItemContainersStrict,
|
|
4719
|
+
GENERIC.formLabelSimple
|
|
4720
|
+
]);
|
|
4148
4721
|
return {
|
|
4149
4722
|
count: data.elements.length,
|
|
4150
4723
|
elements: data.elements,
|
|
@@ -4321,6 +4894,8 @@ async function enrichSemanticMiss(e, config, exec) {
|
|
|
4321
4894
|
}
|
|
4322
4895
|
async function clickTool(config, args, exec) {
|
|
4323
4896
|
const page = await getPage(config);
|
|
4897
|
+
await assertExpectedUrl(page, args.expectUrl, "click");
|
|
4898
|
+
const extWarning = drainExternalNavWarning(page);
|
|
4324
4899
|
let targetRes;
|
|
4325
4900
|
try {
|
|
4326
4901
|
targetRes = await resolveTargetLocator(page, args);
|
|
@@ -4423,6 +4998,7 @@ async function clickTool(config, args, exec) {
|
|
|
4423
4998
|
clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
|
|
4424
4999
|
effect,
|
|
4425
5000
|
...quiet ? { effectHint: "点击后未观察到任何 DOM/URL/网络变化,可能未生效(被遮挡/事件被吞/前置条件未满足);建议 browser_snapshot 核实或换目标" } : {},
|
|
5001
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4426
5002
|
...ambiguityFields(targetRes),
|
|
4427
5003
|
...targetRes.viaBridge === true ? { viaBridge: true } : {},
|
|
4428
5004
|
...await pageIdentity(page)
|
|
@@ -4430,6 +5006,7 @@ async function clickTool(config, args, exec) {
|
|
|
4430
5006
|
}
|
|
4431
5007
|
return {
|
|
4432
5008
|
clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
|
|
5009
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4433
5010
|
...ambiguityFields(targetRes),
|
|
4434
5011
|
...targetRes.viaBridge === true ? { viaBridge: true } : {},
|
|
4435
5012
|
...await pageIdentity(page)
|
|
@@ -4437,6 +5014,8 @@ async function clickTool(config, args, exec) {
|
|
|
4437
5014
|
}
|
|
4438
5015
|
async function typeTool(config, args) {
|
|
4439
5016
|
const page = await getPage(config);
|
|
5017
|
+
await assertExpectedUrl(page, args.expectUrl, "type");
|
|
5018
|
+
const extWarning = drainExternalNavWarning(page);
|
|
4440
5019
|
const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
|
|
4441
5020
|
"textbox",
|
|
4442
5021
|
"searchbox",
|
|
@@ -4451,27 +5030,54 @@ async function typeTool(config, args) {
|
|
|
4451
5030
|
const selector = String(args.ref ?? args.selector ?? "");
|
|
4452
5031
|
if (await fillEditorHelper(page, selector, text)) return {
|
|
4453
5032
|
typed: true,
|
|
4454
|
-
|
|
5033
|
+
typedVia: "editor",
|
|
4455
5034
|
selector,
|
|
4456
5035
|
...ambiguityFields(targetRes),
|
|
4457
5036
|
...await pageIdentity(page)
|
|
4458
5037
|
};
|
|
4459
5038
|
}
|
|
5039
|
+
let typedVia = "clicked";
|
|
5040
|
+
let focusedElement;
|
|
4460
5041
|
if (args.clear === true) {
|
|
4461
5042
|
if (isHuman) {
|
|
4462
5043
|
await target.fill("");
|
|
4463
5044
|
await humanType(page, text, { speed: config.humanizeSpeed });
|
|
4464
5045
|
} else await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
|
|
5046
|
+
typedVia = "fill";
|
|
4465
5047
|
} else {
|
|
4466
5048
|
const ambiguous = args.strict === true || (targetRes.matchedCount ?? 0) > 1;
|
|
5049
|
+
let clickOk = false;
|
|
4467
5050
|
try {
|
|
4468
5051
|
await target.click({ timeout: Math.min(args.timeoutMs ?? config.timeoutMs, 5e3) });
|
|
5052
|
+
clickOk = true;
|
|
4469
5053
|
} catch (e) {
|
|
4470
5054
|
if (ambiguous) throw new Error(`type 定位目标存在歧义(命中 ${targetRes.matchedCount ?? "?"} 个元素)且首个不可交互,已中止;请用 scope 限定或 browser_snapshot 取 ref 消歧;原始错误: ${e instanceof Error ? e.message : String(e)}`);
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
5055
|
+
try {
|
|
5056
|
+
await target.click({
|
|
5057
|
+
timeout: 2e3,
|
|
5058
|
+
force: true
|
|
5059
|
+
});
|
|
5060
|
+
clickOk = true;
|
|
5061
|
+
} catch {}
|
|
5062
|
+
}
|
|
5063
|
+
if (!clickOk) {
|
|
5064
|
+
const probe = await withTimeout(target.evaluate((el) => {
|
|
5065
|
+
const ae = document.activeElement;
|
|
5066
|
+
if (ae === null) return {
|
|
5067
|
+
owns: false,
|
|
5068
|
+
desc: null
|
|
5069
|
+
};
|
|
5070
|
+
return {
|
|
5071
|
+
owns: ae === el || el.contains(ae),
|
|
5072
|
+
desc: ae.outerHTML.slice(0, 200)
|
|
5073
|
+
};
|
|
5074
|
+
}), 2e3, "焦点归属探测").catch(() => ({
|
|
5075
|
+
owns: false,
|
|
5076
|
+
desc: null
|
|
5077
|
+
}));
|
|
5078
|
+
focusedElement = probe.desc ?? void 0;
|
|
5079
|
+
if (!probe.owns) throw new Error(`type 目标不可点击且焦点不在目标上,已中止输入(避免盲打到错误输入框)。当前焦点: ${focusedElement ?? "(无法探测/无焦点)"};目标: ${String(args.ref ?? args.selector ?? args.what ?? "")}。建议 browser_snapshot 核实目标状态,或 browser_wait_for 等元素可交互`);
|
|
5080
|
+
typedVia = "focus-kept";
|
|
4475
5081
|
}
|
|
4476
5082
|
if (isHuman) await humanType(page, text, { speed: config.humanizeSpeed });
|
|
4477
5083
|
else await page.keyboard.type(text, { delay: args.delayMs ?? 0 });
|
|
@@ -4479,6 +5085,9 @@ async function typeTool(config, args) {
|
|
|
4479
5085
|
if (args.submit === true) await page.keyboard.press("Enter");
|
|
4480
5086
|
return {
|
|
4481
5087
|
typed: true,
|
|
5088
|
+
typedVia,
|
|
5089
|
+
...focusedElement !== void 0 ? { focusedElement } : {},
|
|
5090
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4482
5091
|
selector: String(args.ref ?? args.selector),
|
|
4483
5092
|
submit: args.submit === true,
|
|
4484
5093
|
...ambiguityFields(targetRes),
|
|
@@ -4489,6 +5098,8 @@ async function pressTool(config, args) {
|
|
|
4489
5098
|
const page = await getPage(config);
|
|
4490
5099
|
const key = String(args.key);
|
|
4491
5100
|
let warning;
|
|
5101
|
+
const extWarning = drainExternalNavWarning(page);
|
|
5102
|
+
if (extWarning !== void 0) warning = warning !== void 0 ? `${warning};${extWarning}` : extWarning;
|
|
4492
5103
|
if (key === "Escape" && args.selector === void 0 && args.ref === void 0) {
|
|
4493
5104
|
const checkState = await page.evaluate(() => {
|
|
4494
5105
|
return {
|
|
@@ -4532,6 +5143,8 @@ async function pressTool(config, args) {
|
|
|
4532
5143
|
*/
|
|
4533
5144
|
async function selectTool(config, args) {
|
|
4534
5145
|
const page = await getPage(config);
|
|
5146
|
+
await assertExpectedUrl(page, args.expectUrl, "select");
|
|
5147
|
+
const extWarning = drainExternalNavWarning(page);
|
|
4535
5148
|
const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
|
|
4536
5149
|
"combobox",
|
|
4537
5150
|
"select",
|
|
@@ -4554,6 +5167,7 @@ async function selectTool(config, args) {
|
|
|
4554
5167
|
clear: args.clear === true
|
|
4555
5168
|
})).selected,
|
|
4556
5169
|
selector: label,
|
|
5170
|
+
...extWarning !== void 0 ? { warning: extWarning } : {},
|
|
4557
5171
|
...ambiguityFields(targetRes),
|
|
4558
5172
|
...await pageIdentity(page)
|
|
4559
5173
|
};
|
|
@@ -4561,8 +5175,8 @@ async function selectTool(config, args) {
|
|
|
4561
5175
|
async function evalTool(config, args) {
|
|
4562
5176
|
const page = await getPage(config);
|
|
4563
5177
|
const { script, unwrapped } = unwrapDoubleEncodedScript(String(args.script ?? ""));
|
|
4564
|
-
const
|
|
4565
|
-
const raw = await page.evaluate(
|
|
5178
|
+
const { code, mode } = buildEvalScript(script);
|
|
5179
|
+
const raw = await page.evaluate(code);
|
|
4566
5180
|
let result;
|
|
4567
5181
|
try {
|
|
4568
5182
|
result = JSON.parse(JSON.stringify(raw ?? null));
|
|
@@ -4571,6 +5185,7 @@ async function evalTool(config, args) {
|
|
|
4571
5185
|
}
|
|
4572
5186
|
return {
|
|
4573
5187
|
result,
|
|
5188
|
+
mode,
|
|
4574
5189
|
...unwrapped ? { scriptUnwrapped: true } : {}
|
|
4575
5190
|
};
|
|
4576
5191
|
}
|
|
@@ -4771,6 +5386,41 @@ async function formFillTool(config, args) {
|
|
|
4771
5386
|
...submitted !== void 0 ? { submitted } : {}
|
|
4772
5387
|
};
|
|
4773
5388
|
}
|
|
5389
|
+
/**
|
|
5390
|
+
* browser_drag:在页面里把源元素拖到目标元素上。
|
|
5391
|
+
* 源/目标各自走统一定位管线(selector / ref / what+scope / mark 四选一前缀),
|
|
5392
|
+
* 支持 HTML5 原生拖放(dragTo)与鼠标拖动类控件(物理鼠标序列降级);
|
|
5393
|
+
* 跨 Frame 拖拽自动绕过 dragTo 的同 Frame 限制。
|
|
5394
|
+
*/
|
|
5395
|
+
async function dragTool(config, args) {
|
|
5396
|
+
const pickSide = (side) => {
|
|
5397
|
+
const remapped = {};
|
|
5398
|
+
if (typeof args[side] === "string" && args[side] !== "") remapped.selector = args[side];
|
|
5399
|
+
if (typeof args[`${side}Ref`] === "string" && args[`${side}Ref`] !== "") remapped.ref = args[`${side}Ref`];
|
|
5400
|
+
if (typeof args[`${side}What`] === "string" && args[`${side}What`].trim() !== "") remapped.what = args[`${side}What`];
|
|
5401
|
+
if (typeof args[`${side}Mark`] === "number") remapped.mark = args[`${side}Mark`];
|
|
5402
|
+
if (typeof args[`${side}Scope`] === "string" && args[`${side}Scope`] !== "") remapped.scope = args[`${side}Scope`];
|
|
5403
|
+
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 标徽)至少给一个`);
|
|
5404
|
+
return remapped;
|
|
5405
|
+
};
|
|
5406
|
+
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);
|
|
5407
|
+
const srcArgs = pickSide("source");
|
|
5408
|
+
const tgtArgs = pickSide("target");
|
|
5409
|
+
const page = await getPage(config);
|
|
5410
|
+
const srcRes = await resolveTargetLocator(page, srcArgs);
|
|
5411
|
+
const tgtRes = await resolveTargetLocator(page, tgtArgs);
|
|
5412
|
+
const crossFrame = srcRes.container !== tgtRes.container;
|
|
5413
|
+
return {
|
|
5414
|
+
dragged: true,
|
|
5415
|
+
via: await dragAndDropLocators(page, srcRes.locator, tgtRes.locator, {
|
|
5416
|
+
timeoutMs: typeof args.timeoutMs === "number" ? args.timeoutMs : void 0,
|
|
5417
|
+
preferMouse: crossFrame
|
|
5418
|
+
}),
|
|
5419
|
+
source: describeSide(srcArgs),
|
|
5420
|
+
target: describeSide(tgtArgs),
|
|
5421
|
+
...await pageIdentity(page)
|
|
5422
|
+
};
|
|
5423
|
+
}
|
|
4774
5424
|
//#endregion
|
|
4775
5425
|
//#region lib/types/browser/tools/testing.js
|
|
4776
5426
|
async function waitTool(config, args) {
|
|
@@ -5492,56 +6142,49 @@ function previewHighlight(page, selector, label) {
|
|
|
5492
6142
|
}).catch(() => {});
|
|
5493
6143
|
}
|
|
5494
6144
|
/**
|
|
5495
|
-
* 常见认证/登录相关的 URL 特征正则,用于防止跳板页提前误判。
|
|
5496
|
-
*/
|
|
5497
|
-
const AUTH_URL_PATTERN = /login|signin|sign-in|oauth|passport|sso|cas|challenge|captcha|verify|auth\b/i;
|
|
5498
|
-
/**
|
|
5499
6145
|
* 等待人工登录底层执行(独立导出供测试注入 fake page)。
|
|
5500
|
-
*
|
|
5501
|
-
* 1.
|
|
5502
|
-
*
|
|
5503
|
-
*
|
|
5504
|
-
*
|
|
5505
|
-
*
|
|
6146
|
+
* 完成判定(按优先级):
|
|
6147
|
+
* 1. **用户主动告知(主通道)**:pendingLoginWait 单槽状态机,面板常驻登录等待卡
|
|
6148
|
+
* 「我已完成登录」按钮 → resolveLoginWait(id),500ms 确认节拍;
|
|
6149
|
+
* 2. 显式确定性条件:successSelector(任一存活标签可见)/ successUrl(URL 正则,
|
|
6150
|
+
* 任一存活标签命中),pollIntervalMs 节拍多标签扫描,命中即把目标页切为命中页;
|
|
6151
|
+
* 3. 超时返回 loggedIn:false(附 waitedMs 与 detail,不抛错)。
|
|
6152
|
+
*
|
|
6153
|
+
* 登录状态不再自动猜测:URL 认证特征/密码框消除/SPA 签名启发式对「登录表单
|
|
6154
|
+
* 隐藏不卸载(keep-alive/微前端)」「登录后 URL 仍含 auth 子串」的 SPA 会永久
|
|
6155
|
+
* 漏判(用户已登录仍空转到超时),已整体移除——登录完成由用户说了算。
|
|
6156
|
+
* 保留:存活标签感知(原标签关闭自愈迁移、新标签经 onNewPageDetected 注册)。
|
|
5506
6157
|
*/
|
|
5507
6158
|
async function runWaitForLoginOnPage(initialPage, args, onNewPageDetected) {
|
|
5508
|
-
typeof args.instruction === "string" && args.instruction.trim()
|
|
6159
|
+
const instruction = typeof args.instruction === "string" && args.instruction.trim() ? args.instruction.trim() : "请在浏览器窗口中完成登录";
|
|
5509
6160
|
const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 12e4) || 12e4, 1e3), 6e5);
|
|
5510
6161
|
const pollIntervalMs = Math.min(Math.max(Number(args.pollIntervalMs ?? 2e3) || 2e3, 100), 5e3);
|
|
6162
|
+
const successSelector = typeof args.successSelector === "string" ? args.successSelector : "";
|
|
6163
|
+
const successUrl = typeof args.successUrl === "string" ? args.successUrl : "";
|
|
5511
6164
|
let currentPage = initialPage;
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
return await p.evaluate(() => !!document.querySelector("input[type=\"password\"]"));
|
|
5519
|
-
} catch {
|
|
5520
|
-
return false;
|
|
5521
|
-
}
|
|
5522
|
-
};
|
|
5523
|
-
const getPageSig = async (p) => {
|
|
5524
|
-
try {
|
|
5525
|
-
return await p.evaluate(() => {
|
|
5526
|
-
const body = document.body;
|
|
5527
|
-
return `${location.href}|${body ? body.innerText.length : 0}|${document.querySelectorAll("input,button,a").length}`;
|
|
5528
|
-
});
|
|
5529
|
-
} catch {
|
|
5530
|
-
try {
|
|
5531
|
-
return p.url();
|
|
5532
|
-
} catch {
|
|
5533
|
-
return "";
|
|
5534
|
-
}
|
|
5535
|
-
}
|
|
5536
|
-
};
|
|
5537
|
-
const startHasPassword = await checkHasPassword(initialPage);
|
|
5538
|
-
const startSig = args.successSelector || args.successUrl ? "" : await getPageSig(initialPage);
|
|
6165
|
+
const id = `login-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
6166
|
+
setPendingLoginWait({
|
|
6167
|
+
id,
|
|
6168
|
+
instruction,
|
|
6169
|
+
startedAt: Date.now()
|
|
6170
|
+
});
|
|
5539
6171
|
const startedAt = Date.now();
|
|
5540
6172
|
let loggedIn = false;
|
|
5541
|
-
let detail = `等待登录超时(${timeoutMs}ms)
|
|
6173
|
+
let detail = `等待登录超时(${timeoutMs}ms),用户未在面板确认登录完成`;
|
|
6174
|
+
let lastExplicitCheck = 0;
|
|
5542
6175
|
try {
|
|
5543
6176
|
const deadline = startedAt + timeoutMs;
|
|
5544
6177
|
while (Date.now() < deadline) {
|
|
6178
|
+
const pending = getPendingLoginWait();
|
|
6179
|
+
if (pending !== null && pending.id !== id) {
|
|
6180
|
+
detail = "登录等待已被新的等待请求取代";
|
|
6181
|
+
break;
|
|
6182
|
+
}
|
|
6183
|
+
if (pending !== null && pending.done) {
|
|
6184
|
+
loggedIn = true;
|
|
6185
|
+
detail = "用户已在面板确认完成登录";
|
|
6186
|
+
break;
|
|
6187
|
+
}
|
|
5545
6188
|
let candidatePages = [];
|
|
5546
6189
|
try {
|
|
5547
6190
|
const context = typeof currentPage.context === "function" ? currentPage.context() : null;
|
|
@@ -5566,76 +6209,36 @@ async function runWaitForLoginOnPage(initialPage, args, onNewPageDetected) {
|
|
|
5566
6209
|
isCurrentClosed = false;
|
|
5567
6210
|
}
|
|
5568
6211
|
}
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
currentPage = p;
|
|
5576
|
-
detail = `命中 successSelector: ${args.successSelector}`;
|
|
5577
|
-
break;
|
|
5578
|
-
}
|
|
5579
|
-
} catch {}
|
|
5580
|
-
} else if (args.successUrl) {
|
|
5581
|
-
const re = new RegExp(String(args.successUrl));
|
|
5582
|
-
const targets = candidatePages.length > 0 ? candidatePages : isCurrentClosed ? [] : [currentPage];
|
|
5583
|
-
for (const p of targets) try {
|
|
5584
|
-
const u = p.url();
|
|
5585
|
-
if (re.test(u)) {
|
|
6212
|
+
if ((successSelector !== "" || successUrl !== "") && Date.now() - lastExplicitCheck >= pollIntervalMs) {
|
|
6213
|
+
lastExplicitCheck = Date.now();
|
|
6214
|
+
const targets = candidatePages.length > 0 ? candidatePages : isCurrentClosed ? [] : [currentPage];
|
|
6215
|
+
for (const p of targets) try {
|
|
6216
|
+
if (successSelector !== "") {
|
|
6217
|
+
if (await p.locator(successSelector).first().isVisible()) {
|
|
5586
6218
|
loggedIn = true;
|
|
5587
6219
|
currentPage = p;
|
|
5588
|
-
detail = `命中
|
|
6220
|
+
detail = `命中 successSelector: ${successSelector}`;
|
|
5589
6221
|
break;
|
|
5590
6222
|
}
|
|
5591
|
-
}
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
try {
|
|
5597
|
-
curUrl = target.url();
|
|
5598
|
-
} catch {}
|
|
5599
|
-
if (curUrl && curUrl !== "about:blank") {
|
|
5600
|
-
if (curUrl !== startUrl) {
|
|
5601
|
-
if (!AUTH_URL_PATTERN.test(curUrl)) {
|
|
5602
|
-
loggedIn = true;
|
|
5603
|
-
currentPage = target;
|
|
5604
|
-
detail = `URL 离开登录页跳转至 ${curUrl}`;
|
|
5605
|
-
} else {
|
|
5606
|
-
const hasPwd = await checkHasPassword(target);
|
|
5607
|
-
if (startHasPassword && !hasPwd) {
|
|
5608
|
-
loggedIn = true;
|
|
5609
|
-
currentPage = target;
|
|
5610
|
-
detail = `登录密码输入框已消除,页面跳转至 ${curUrl}`;
|
|
5611
|
-
}
|
|
5612
|
-
}
|
|
5613
|
-
} else {
|
|
5614
|
-
const hasPwd = await checkHasPassword(target);
|
|
5615
|
-
if (startHasPassword && !hasPwd) {
|
|
5616
|
-
loggedIn = true;
|
|
5617
|
-
currentPage = target;
|
|
5618
|
-
detail = "登录密码输入框已消除(SPA 登录完成)";
|
|
5619
|
-
} else if (await getPageSig(target) !== startSig && (!startHasPassword || !hasPwd)) {
|
|
5620
|
-
loggedIn = true;
|
|
5621
|
-
currentPage = target;
|
|
5622
|
-
detail = "页面内容签名变更(SPA 登录完成)";
|
|
5623
|
-
}
|
|
5624
|
-
}
|
|
5625
|
-
}
|
|
6223
|
+
} else if (new RegExp(successUrl).test(p.url())) {
|
|
6224
|
+
loggedIn = true;
|
|
6225
|
+
currentPage = p;
|
|
6226
|
+
detail = `命中 successUrl: ${successUrl}`;
|
|
6227
|
+
break;
|
|
5626
6228
|
}
|
|
5627
|
-
}
|
|
5628
|
-
} catch (e) {
|
|
5629
|
-
detail = `检查登录状态出错: ${e instanceof Error ? e.message : String(e)}`;
|
|
6229
|
+
} catch {}
|
|
5630
6230
|
}
|
|
5631
6231
|
if (loggedIn) break;
|
|
5632
6232
|
try {
|
|
5633
|
-
await currentPage.waitForTimeout(
|
|
6233
|
+
await currentPage.waitForTimeout(500);
|
|
5634
6234
|
} catch {
|
|
5635
|
-
await sleep$1(
|
|
6235
|
+
await sleep$1(500);
|
|
5636
6236
|
}
|
|
5637
6237
|
}
|
|
5638
|
-
} finally {
|
|
6238
|
+
} finally {
|
|
6239
|
+
const pending = getPendingLoginWait();
|
|
6240
|
+
if (pending !== null && pending.id === id) clearPendingLoginWait();
|
|
6241
|
+
}
|
|
5639
6242
|
return {
|
|
5640
6243
|
loggedIn,
|
|
5641
6244
|
waitedMs: Date.now() - startedAt,
|
|
@@ -5645,11 +6248,15 @@ async function runWaitForLoginOnPage(initialPage, args, onNewPageDetected) {
|
|
|
5645
6248
|
}
|
|
5646
6249
|
/**
|
|
5647
6250
|
* 等待人工登录完成(配合 headless:false 使用):agent 打开登录页后调用本工具,
|
|
5648
|
-
*
|
|
5649
|
-
*
|
|
5650
|
-
*
|
|
6251
|
+
* 用户在浏览器窗口完成登录(输入/扫码/双因素)后,在实时画面面板的**常驻登录
|
|
6252
|
+
* 等待卡**上点「我已完成登录」主动告知,工具立即返回(500ms 确认节拍)。
|
|
6253
|
+
* 亦支持显式确定性条件 successSelector/successUrl(多标签扫描自动判定)。
|
|
6254
|
+
* 不做任何登录状态启发式猜测。超时返回 loggedIn:false(附 waitedMs 与
|
|
6255
|
+
* detail,不抛错)。无面板环境(MCP 独立模式)必须显式给条件,否则 fail-fast。
|
|
5651
6256
|
*/
|
|
5652
6257
|
async function waitForLoginTool(config, args) {
|
|
6258
|
+
const hasExplicit = typeof args.successSelector === "string" && args.successSelector !== "" || typeof args.successUrl === "string" && args.successUrl !== "";
|
|
6259
|
+
if (!isInteractivePanelPresent() && !hasExplicit) throw new Error("等待人工登录失败: 当前环境无交互面板,用户无法点「我已完成登录」确认。请显式提供 successSelector(登录成功后出现的元素,如 \"#avatar\")或 successUrl(登录成功后 URL 应匹配的正则)后重试");
|
|
5653
6260
|
const page = await getPage(config);
|
|
5654
6261
|
const state = stateOf();
|
|
5655
6262
|
const result = await runWaitForLoginOnPage(page, args, (newPage) => {
|
|
@@ -7624,7 +8231,11 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7624
8231
|
type: "boolean",
|
|
7625
8232
|
description: "多命中即抛错(对齐 Playwright strict),要求定位唯一;默认 false 操作第 1 个并在返回中提示命中数。"
|
|
7626
8233
|
},
|
|
7627
|
-
timeoutMs: { type: "integer" }
|
|
8234
|
+
timeoutMs: { type: "integer" },
|
|
8235
|
+
expectUrl: {
|
|
8236
|
+
type: "string",
|
|
8237
|
+
description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
|
|
8238
|
+
}
|
|
7628
8239
|
},
|
|
7629
8240
|
additionalProperties: false
|
|
7630
8241
|
},
|
|
@@ -7652,6 +8263,10 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7652
8263
|
viaBridge: {
|
|
7653
8264
|
type: "boolean",
|
|
7654
8265
|
description: "what 未命中时经页面 __DSH_BRIDGE__(vite-plugin-dsh-bridge)兜底命中。"
|
|
8266
|
+
},
|
|
8267
|
+
warning: {
|
|
8268
|
+
type: "string",
|
|
8269
|
+
description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
|
|
7655
8270
|
}
|
|
7656
8271
|
},
|
|
7657
8272
|
required: [
|
|
@@ -7666,7 +8281,7 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7666
8281
|
}),
|
|
7667
8282
|
definePlainTool({
|
|
7668
8283
|
name: "browser_type",
|
|
7669
|
-
description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror
|
|
8284
|
+
description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。定位与点击均失败时做焦点归属校验:焦点不在目标内(含子元素)则中止输入并附当前焦点线索,不会盲打到错误输入框;返回 typedVia 标明实际输入通道。expectUrl 传入时先校验当前页面 URL,不符直接报错(防页面被切换后打错位置)。",
|
|
7670
8285
|
parameters: {
|
|
7671
8286
|
type: "object",
|
|
7672
8287
|
properties: {
|
|
@@ -7702,7 +8317,11 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7702
8317
|
description: "多命中即抛错(对齐 Playwright strict);多命中或 strict 时不再 force 降级点击,遮罩拦截会直接暴露。"
|
|
7703
8318
|
},
|
|
7704
8319
|
delayMs: { type: "integer" },
|
|
7705
|
-
timeoutMs: { type: "integer" }
|
|
8320
|
+
timeoutMs: { type: "integer" },
|
|
8321
|
+
expectUrl: {
|
|
8322
|
+
type: "string",
|
|
8323
|
+
description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
|
|
8324
|
+
}
|
|
7706
8325
|
},
|
|
7707
8326
|
required: ["text"],
|
|
7708
8327
|
additionalProperties: false
|
|
@@ -7712,6 +8331,20 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7712
8331
|
additionalProperties: false,
|
|
7713
8332
|
properties: {
|
|
7714
8333
|
typed: { type: "boolean" },
|
|
8334
|
+
typedVia: {
|
|
8335
|
+
type: "string",
|
|
8336
|
+
enum: [
|
|
8337
|
+
"fill",
|
|
8338
|
+
"clicked",
|
|
8339
|
+
"focus-kept",
|
|
8340
|
+
"editor"
|
|
8341
|
+
],
|
|
8342
|
+
description: "实际输入通道:fill=clear 清空重填;clicked=点击获得焦点;focus-kept=点击超时但焦点已在目标内续打;editor=编辑器适配。"
|
|
8343
|
+
},
|
|
8344
|
+
focusedElement: {
|
|
8345
|
+
type: "string",
|
|
8346
|
+
description: "focus-kept 通道下当前焦点元素 outerHTML 片段(调试线索)。"
|
|
8347
|
+
},
|
|
7715
8348
|
selector: { type: "string" },
|
|
7716
8349
|
submit: { type: "boolean" },
|
|
7717
8350
|
url: { type: "string" },
|
|
@@ -7727,6 +8360,10 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7727
8360
|
hint: {
|
|
7728
8361
|
type: "string",
|
|
7729
8362
|
description: "matchedCount>1 时的消歧指引。"
|
|
8363
|
+
},
|
|
8364
|
+
warning: {
|
|
8365
|
+
type: "string",
|
|
8366
|
+
description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
|
|
7730
8367
|
}
|
|
7731
8368
|
},
|
|
7732
8369
|
required: [
|
|
@@ -7841,7 +8478,11 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7841
8478
|
type: "boolean",
|
|
7842
8479
|
description: "多命中即抛错(对齐 Playwright strict)。"
|
|
7843
8480
|
},
|
|
7844
|
-
timeoutMs: { type: "integer" }
|
|
8481
|
+
timeoutMs: { type: "integer" },
|
|
8482
|
+
expectUrl: {
|
|
8483
|
+
type: "string",
|
|
8484
|
+
description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
|
|
8485
|
+
}
|
|
7845
8486
|
},
|
|
7846
8487
|
required: ["option"],
|
|
7847
8488
|
additionalProperties: false
|
|
@@ -7865,6 +8506,10 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7865
8506
|
hint: {
|
|
7866
8507
|
type: "string",
|
|
7867
8508
|
description: "matchedCount>1 时的消歧指引。"
|
|
8509
|
+
},
|
|
8510
|
+
warning: {
|
|
8511
|
+
type: "string",
|
|
8512
|
+
description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
|
|
7868
8513
|
}
|
|
7869
8514
|
},
|
|
7870
8515
|
required: [
|
|
@@ -7878,9 +8523,95 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7878
8523
|
timeoutMs: timeout(),
|
|
7879
8524
|
execute: (args) => selectTool(config, args)
|
|
7880
8525
|
}),
|
|
8526
|
+
definePlainTool({
|
|
8527
|
+
name: "browser_drag",
|
|
8528
|
+
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。源/目标不可见或零命中时抛错并附诊断。",
|
|
8529
|
+
parameters: {
|
|
8530
|
+
type: "object",
|
|
8531
|
+
properties: {
|
|
8532
|
+
source: {
|
|
8533
|
+
type: "string",
|
|
8534
|
+
description: "源元素 CSS 选择器;与 sourceRef/sourceWhat/sourceMark 互斥。"
|
|
8535
|
+
},
|
|
8536
|
+
sourceRef: {
|
|
8537
|
+
type: "string",
|
|
8538
|
+
description: "源元素 aria 快照 ref(如 'e3');与 source/sourceWhat/sourceMark 互斥。"
|
|
8539
|
+
},
|
|
8540
|
+
sourceWhat: {
|
|
8541
|
+
type: "string",
|
|
8542
|
+
description: "源元素意图描述(如 '任务卡片 A'):页内打分语义定位;与 source/sourceRef/sourceMark 互斥;配合 sourceScope。"
|
|
8543
|
+
},
|
|
8544
|
+
sourceMark: {
|
|
8545
|
+
type: "integer",
|
|
8546
|
+
description: "源元素 SoM 标徽编号(browser_screenshot { som: true });与 source/sourceRef/sourceWhat 互斥。"
|
|
8547
|
+
},
|
|
8548
|
+
sourceScope: {
|
|
8549
|
+
type: "string",
|
|
8550
|
+
description: "配合 sourceWhat 限定扫描范围的容器选择器。"
|
|
8551
|
+
},
|
|
8552
|
+
target: {
|
|
8553
|
+
type: "string",
|
|
8554
|
+
description: "目标元素 CSS 选择器;与 targetRef/targetWhat/targetMark 互斥。"
|
|
8555
|
+
},
|
|
8556
|
+
targetRef: {
|
|
8557
|
+
type: "string",
|
|
8558
|
+
description: "目标元素 aria 快照 ref;与 target/targetWhat/targetMark 互斥。"
|
|
8559
|
+
},
|
|
8560
|
+
targetWhat: {
|
|
8561
|
+
type: "string",
|
|
8562
|
+
description: "目标元素意图描述(如 '待办列'):页内打分语义定位;与 target/targetRef/targetMark 互斥;配合 targetScope。"
|
|
8563
|
+
},
|
|
8564
|
+
targetMark: {
|
|
8565
|
+
type: "integer",
|
|
8566
|
+
description: "目标元素 SoM 标徽编号;与 target/targetRef/targetWhat 互斥。"
|
|
8567
|
+
},
|
|
8568
|
+
targetScope: {
|
|
8569
|
+
type: "string",
|
|
8570
|
+
description: "配合 targetWhat 限定扫描范围的容器选择器。"
|
|
8571
|
+
},
|
|
8572
|
+
timeoutMs: { type: "integer" }
|
|
8573
|
+
},
|
|
8574
|
+
additionalProperties: false
|
|
8575
|
+
},
|
|
8576
|
+
outputSchema: {
|
|
8577
|
+
type: "object",
|
|
8578
|
+
additionalProperties: false,
|
|
8579
|
+
properties: {
|
|
8580
|
+
dragged: {
|
|
8581
|
+
type: "boolean",
|
|
8582
|
+
description: "拖拽序列已执行(true 不保证业务侧生效,drop 被拒绝也为 true)。"
|
|
8583
|
+
},
|
|
8584
|
+
via: {
|
|
8585
|
+
type: "string",
|
|
8586
|
+
description: "实际执行通道:'dragTo'(HTML5/组件库)或 'mouse'(物理鼠标,含跨 Frame 与降级)。"
|
|
8587
|
+
},
|
|
8588
|
+
source: {
|
|
8589
|
+
type: "string",
|
|
8590
|
+
description: "实际使用的源定位描述(what:/ref:/mark:/selector)。"
|
|
8591
|
+
},
|
|
8592
|
+
target: {
|
|
8593
|
+
type: "string",
|
|
8594
|
+
description: "实际使用的目标定位描述。"
|
|
8595
|
+
},
|
|
8596
|
+
url: { type: "string" },
|
|
8597
|
+
title: { type: "string" }
|
|
8598
|
+
},
|
|
8599
|
+
required: [
|
|
8600
|
+
"dragged",
|
|
8601
|
+
"via",
|
|
8602
|
+
"source",
|
|
8603
|
+
"target",
|
|
8604
|
+
"url",
|
|
8605
|
+
"title"
|
|
8606
|
+
]
|
|
8607
|
+
},
|
|
8608
|
+
render: (_args, value) => `Dragged ${value.source} → ${value.target} (via ${value.via})`,
|
|
8609
|
+
timeoutMs: timeout(),
|
|
8610
|
+
execute: (args) => dragTool(config, args)
|
|
8611
|
+
}),
|
|
7881
8612
|
definePlainTool({
|
|
7882
8613
|
name: "browser_eval",
|
|
7883
|
-
description: "在页面上下文执行一段 JavaScript
|
|
8614
|
+
description: "在页面上下文执行一段 JavaScript。表达式(含多行对象字面量/箭头函数 IIFE/顶层 await)直接返回值;语句脚本自动 return 最后一个顶层表达式的值,也可显式 return。结果必须是可 JSON 序列化的值,返回 mode 标明实际执行形态。",
|
|
7884
8615
|
parameters: {
|
|
7885
8616
|
type: "object",
|
|
7886
8617
|
properties: { script: {
|
|
@@ -7895,6 +8626,15 @@ function getInteractionTools(config, timeout, stepImpls) {
|
|
|
7895
8626
|
additionalProperties: false,
|
|
7896
8627
|
properties: {
|
|
7897
8628
|
result: {},
|
|
8629
|
+
mode: {
|
|
8630
|
+
type: "string",
|
|
8631
|
+
enum: [
|
|
8632
|
+
"expression",
|
|
8633
|
+
"statements-auto-return",
|
|
8634
|
+
"statements"
|
|
8635
|
+
],
|
|
8636
|
+
description: "实际执行形态:expression=表达式直取值(支持顶层 await 与多行对象字面量);statements-auto-return=语句体自动 return 最后一个顶层表达式;statements=语句体(显式 return 或纯副作用,无返回值时 result 为 null)。"
|
|
8637
|
+
},
|
|
7898
8638
|
scriptUnwrapped: {
|
|
7899
8639
|
type: "boolean",
|
|
7900
8640
|
description: "入参 script 疑似被 JSON 双重序列化(以引号包裹且含 \\n 字面量),已自动解码一层。"
|
|
@@ -8695,13 +9435,13 @@ function getCollaborationTools(config, timeout, stepImpls) {
|
|
|
8695
9435
|
return [
|
|
8696
9436
|
definePlainTool({
|
|
8697
9437
|
name: "browser_wait_for_login",
|
|
8698
|
-
description: "等待人工登录完成。agent
|
|
9438
|
+
description: "等待人工登录完成。agent 在登录页调用本工具后,用户在浏览器窗口完成登录(有头模式 headless:false;配 userDataDir 登录态会持久化),然后在实时画面面板的常驻登录等待卡上点「我已完成登录」主动告知,工具立即返回(500ms 确认节拍)——不猜测登录状态,完成与否由用户说了算。亦可显式给 successSelector(登录后出现的元素,如 '#avatar')或 successUrl(登录后 URL 正则)作为确定性自动条件(多标签扫描,轮询间隔 2 秒)。超时返回 loggedIn:false(附 waitedMs 与 detail,不抛错)。注意:无面板环境(独立 MCP 模式)用户无法点按钮确认,必须显式给 successSelector 或 successUrl,否则调用直接报错。",
|
|
8699
9439
|
parameters: {
|
|
8700
9440
|
type: "object",
|
|
8701
9441
|
properties: {
|
|
8702
9442
|
instruction: {
|
|
8703
9443
|
type: "string",
|
|
8704
|
-
description: "
|
|
9444
|
+
description: "可选:给用户看的提示说明,展示在面板登录等待卡上(默认「请在浏览器窗口中完成登录」)。"
|
|
8705
9445
|
},
|
|
8706
9446
|
timeoutMs: {
|
|
8707
9447
|
type: "integer",
|
|
@@ -8709,11 +9449,11 @@ function getCollaborationTools(config, timeout, stepImpls) {
|
|
|
8709
9449
|
},
|
|
8710
9450
|
successSelector: {
|
|
8711
9451
|
type: "string",
|
|
8712
|
-
description: "
|
|
9452
|
+
description: "可选:登录成功后应出现的 CSS 选择器(确定性自动条件,任一标签可见即完成)。"
|
|
8713
9453
|
},
|
|
8714
9454
|
successUrl: {
|
|
8715
9455
|
type: "string",
|
|
8716
|
-
description: "
|
|
9456
|
+
description: "可选:登录成功后 URL 应匹配的正则(确定性自动条件)。"
|
|
8717
9457
|
}
|
|
8718
9458
|
},
|
|
8719
9459
|
additionalProperties: false
|
|
@@ -9373,14 +10113,16 @@ function registerTools(ctx, config) {
|
|
|
9373
10113
|
"browser_upload",
|
|
9374
10114
|
"browser_form",
|
|
9375
10115
|
"browser_form_fill",
|
|
9376
|
-
"browser_eval"
|
|
10116
|
+
"browser_eval",
|
|
10117
|
+
"browser_drag"
|
|
9377
10118
|
]);
|
|
9378
10119
|
/** 操作预告:这些工具执行前把目标元素在实时画面上高亮(异步不阻塞)。 */
|
|
9379
10120
|
const PREVIEW_TOOLS = /* @__PURE__ */ new Set([
|
|
9380
10121
|
"browser_click",
|
|
9381
10122
|
"browser_type",
|
|
9382
10123
|
"browser_select",
|
|
9383
|
-
"browser_press"
|
|
10124
|
+
"browser_press",
|
|
10125
|
+
"browser_drag"
|
|
9384
10126
|
]);
|
|
9385
10127
|
/**
|
|
9386
10128
|
* auto 审批闸(confirmMode:"auto" 硬约束):live 调用在 register 包装层拦,
|
|
@@ -9435,8 +10177,8 @@ function registerTools(ctx, config) {
|
|
|
9435
10177
|
sessionTracer.startStep(currentProfile, def.name, args, activePage());
|
|
9436
10178
|
if (PREVIEW_TOOLS.has(def.name)) {
|
|
9437
10179
|
const page = activePage();
|
|
9438
|
-
const selector = typeof args?.selector === "string" && args.selector ? args.selector : typeof args?.ref === "string" && args.ref && page !== null ? pageRef(page, args.ref)?.selector ?? "" : "";
|
|
9439
|
-
if (selector !== "") previewHighlight(page, selector, `${def.name.replace(/^browser_/, "")} ${args?.ref ? `[${args.ref}] ` : ""}${args?.key ?? args?.value ?? args?.text ?? ""}`.trim());
|
|
10180
|
+
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 ?? "" : "";
|
|
10181
|
+
if (selector !== "") previewHighlight(page, selector, `${def.name.replace(/^browser_/, "")} ${args?.ref ? `[${args.ref}] ` : ""}${args?.key ?? args?.value ?? args?.text ?? args?.source ?? ""}`.trim());
|
|
9440
10182
|
}
|
|
9441
10183
|
try {
|
|
9442
10184
|
const result = await original(args, exec);
|
|
@@ -9481,6 +10223,7 @@ function registerTools(ctx, config) {
|
|
|
9481
10223
|
browser_type: (a) => typeTool(config, a),
|
|
9482
10224
|
browser_press: (a) => pressTool(config, a),
|
|
9483
10225
|
browser_select: (a) => selectTool(config, a),
|
|
10226
|
+
browser_drag: (a) => dragTool(config, a),
|
|
9484
10227
|
browser_eval: (a) => evalTool(config, a),
|
|
9485
10228
|
browser_screenshot: (a) => screenshotTool(config, a),
|
|
9486
10229
|
browser_wait: (a) => waitTool(config, a),
|