dsh-browser-view 0.8.0 → 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/lib/mcp.js CHANGED
@@ -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) {
@@ -822,6 +849,53 @@ function injectWalker(entry) {
822
849
  const src = `"use strict"; return function (payload) { var __name = (t, v) => t; const w = (${CORE_SRC})(); return (${entry.toString()})(payload, w); };`;
823
850
  return new Function(src)();
824
851
  }
852
+ const pickTopDialog = injectWalker((payload, w) => {
853
+ const effectiveZ = (el) => {
854
+ let node = el;
855
+ while (node !== null) {
856
+ const cs = getComputedStyle(node);
857
+ if (cs.position !== "static" && cs.zIndex !== "auto") {
858
+ const z = Number.parseInt(cs.zIndex, 10);
859
+ if (Number.isFinite(z)) return z;
860
+ }
861
+ node = w.composedParent(node);
862
+ }
863
+ return 0;
864
+ };
865
+ const visible = (el) => {
866
+ if (!(el instanceof HTMLElement)) return false;
867
+ const cs = getComputedStyle(el);
868
+ if (cs.display === "none" || cs.visibility === "hidden") return false;
869
+ const r = el.getBoundingClientRect();
870
+ return r.width > 0 && r.height > 0;
871
+ };
872
+ const collect = (sel) => {
873
+ try {
874
+ return Array.from(document.querySelectorAll(sel)).filter(visible);
875
+ } catch {
876
+ return [];
877
+ }
878
+ };
879
+ let tier = "precise";
880
+ let nodes = collect(payload.precise);
881
+ if (nodes.length === 0) {
882
+ tier = "fuzzy";
883
+ nodes = collect(payload.fuzzy);
884
+ }
885
+ if (nodes.length === 0) return null;
886
+ const zs = nodes.map((n) => effectiveZ(n));
887
+ const maxZ = Math.max(...zs);
888
+ const top = nodes.filter((n, i) => zs[i] === maxZ);
889
+ const outermost = top.filter((n) => !top.some((m) => m !== n && m.contains(n)));
890
+ const pool = outermost.length > 0 ? outermost : top;
891
+ const winner = pool[pool.length - 1];
892
+ if (winner === void 0) return null;
893
+ return {
894
+ selector: w.cssPath(winner),
895
+ z: maxZ,
896
+ tier
897
+ };
898
+ });
825
899
  //#endregion
826
900
  //#region lib/types/browser/session/recorder.js
827
901
  /** 已保存的录制脚本:name → 步骤数组。 */
@@ -1069,15 +1143,109 @@ function cleanupScreenshots(dir, opts) {
1069
1143
  return removed;
1070
1144
  }
1071
1145
  //#endregion
1146
+ //#region lib/types/browser/primitives/ui-library.js
1147
+ const GENERIC = {
1148
+ dialogWords: [
1149
+ "modal",
1150
+ "drawer",
1151
+ "dialog"
1152
+ ],
1153
+ dialogExcludedParts: [
1154
+ "mask",
1155
+ "wrap",
1156
+ "root",
1157
+ "header",
1158
+ "footer",
1159
+ "title",
1160
+ "close",
1161
+ "body",
1162
+ "btn"
1163
+ ],
1164
+ /** 下拉触发器内部件(点它展开) */
1165
+ dropdownTriggerParts: "[class*='selector'], [role='combobox']",
1166
+ /** 下拉浮层容器的子串词(portal 挂载的面板) */
1167
+ dropdownFloatWords: ["select-dropdown", "popper"],
1168
+ /** 通用下拉选项行(select 状态机消费;库精确层另补) */
1169
+ dropdownOptionRows: "[class*='item-option']:not([class*='empty']), [class*='select-dropdown'] li:not([class*='empty'])",
1170
+ /** 可交互「选项」枚举(elements 工具下拉选项清单消费) */
1171
+ dropdownOptionEnumeration: [
1172
+ "[role='option']",
1173
+ "[role='menuitem']",
1174
+ "[class*='select'][class*='dropdown'] [class*='option']",
1175
+ "[class*='select'][class*='dropdown'] [class*='item']",
1176
+ "[role='listbox'] [role='option']"
1177
+ ].join(", "),
1178
+ /** 多选标签清除按钮 */
1179
+ dropdownClearButtons: "[class*='clear'], [aria-label*='clear']",
1180
+ /** 多选标签移除按钮 */
1181
+ dropdownRemoveButtons: "[class*='remove'], [class*='close'], [aria-label*='close']",
1182
+ /** 空态指示器 */
1183
+ emptyStates: "[class*='empty'], [class*='no-data'], [role='status'], [class*='item-empty']",
1184
+ /** 表单项容器(宽松:BEM/-item 子串 + group + form 元素;label 反查主用) */
1185
+ formItemContainers: "[class*='form-item'], [class*='form__item'], [role='group'], form, .form-group",
1186
+ /**
1187
+ * 表单项容器(严格:带分隔符前缀,避免 platform-item 之类无关门类误命中;
1188
+ * elements 工具页内 labelOf 消费)
1189
+ */
1190
+ formItemContainersStrict: "[class*='-form-item'], [class*='__form-item'], form, .form-group",
1191
+ /** 表单项宿主判定(目标是否处于表单语境;含 label 本身) */
1192
+ formItemHosts: "label, [class*='form-item'], [class*='form__item'], [class*='form-group']",
1193
+ /** 表单项 label(dom 原语 label 反查消费) */
1194
+ formItemLabels: "[class*='form-item-label'], [class*='form-item__label'], [class*='label'], label",
1195
+ /** 表单 label(简化集;elements 工具页内 labelOf 消费) */
1196
+ formLabelSimple: "[class*='label'], label"
1197
+ };
1198
+ /**
1199
+ * 注册新库(示例:naive-ui):
1200
+ * const naiveUi: UILibrarySpec = {
1201
+ * name: "naive-ui",
1202
+ * modalContainers: [".n-modal-container"],
1203
+ * drawerContainers: [".n-drawer-container"],
1204
+ * dropdownContainers: [".n-base-select-menu"],
1205
+ * dropdownOptions: [".n-base-select-option"],
1206
+ * };
1207
+ * 追加到 UI_LIBRARIES 即可,弹窗顶层仲裁与下拉浮层发现自动获得该库精确层支持;
1208
+ * 未注册的库仍由通用子串层兜底。配套要求:补一个该库真页面的 e2e
1209
+ * (playground 模式照抄 test:enterprise),防止想当然的类名污染仲裁。
1210
+ */
1211
+ const UI_LIBRARIES = [{
1212
+ name: "antd",
1213
+ modalContainers: [".ant-modal", ".ant-modal-content"],
1214
+ drawerContainers: [".ant-drawer", ".ant-drawer-content-wrapper"],
1215
+ dropdownContainers: [".ant-select-dropdown"],
1216
+ dropdownOptions: [".ant-select-item-option"]
1217
+ }, {
1218
+ name: "element-plus",
1219
+ modalContainers: [".el-dialog"],
1220
+ drawerContainers: [".el-drawer"],
1221
+ dropdownContainers: [".el-select-dropdown"],
1222
+ dropdownOptions: [".el-select-dropdown__item"]
1223
+ }];
1224
+ const flatOf = (libs, key) => libs.flatMap((lib) => lib[key]);
1225
+ /** 弹窗/抽屉容器(库精确层原料,未加 display:none 预过滤)。 */
1226
+ function dialogContainersOf(libs = UI_LIBRARIES) {
1227
+ return [...flatOf(libs, "modalContainers"), ...flatOf(libs, "drawerContainers")];
1228
+ }
1229
+ /** 弹窗模糊层选择器串:子串词 + 子节点排除(:not 双命名)+ display:none 预过滤。 */
1230
+ function composeDialogFuzzy(words = GENERIC.dialogWords, excludedParts = GENERIC.dialogExcludedParts) {
1231
+ return words.map((word) => [`[class*='${word}']:not([style*='display: none'])`, ...excludedParts.map((part) => `:not([class*='${word}-${part}']):not([class*='${word}__${part}'])`)].join("")).join(", ");
1232
+ }
1233
+ /** 下拉浮层容器选择器列表(库精确层 + 通用子串层,均带 display:none 预过滤)。 */
1234
+ function dropdownFloatSelectors(libs = UI_LIBRARIES) {
1235
+ return [...flatOf(libs, "dropdownContainers"), ...GENERIC.dropdownFloatWords.map((w) => `[class*='${w}']`)].map((sel) => `${sel}:not([style*='display: none']):not([class*='hidden'])`);
1236
+ }
1237
+ /** 下拉选项行选择器列表(标准层 role=option 由消费方自带 + 库精确层 + 通用子串层)。 */
1238
+ function dropdownOptionRowsOf(libs = UI_LIBRARIES) {
1239
+ return [...flatOf(libs, "dropdownOptions").map((sel) => `${sel}:not([class*='empty'])`), GENERIC.dropdownOptionRows];
1240
+ }
1241
+ /** 预组合聚合常量(消费方直接 import)。 */
1242
+ const LIBRARY_DIALOG_CONTAINERS = dialogContainersOf();
1243
+ const LIBRARY_DROPDOWN_CONTAINERS = dropdownFloatSelectors();
1244
+ const LIBRARY_DROPDOWN_OPTIONS = dropdownOptionRowsOf();
1245
+ //#endregion
1072
1246
  //#region lib/types/browser/tools/common.js
1073
- /** 组件库下拉框的可见选项选择器并集 (基于通用 WAI-ARIA 与主流组件库规范)。 */
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(", ");
1247
+ /** 组件库下拉框的可见选项枚举(ui-library 注册表单一来源,值与抽取前等价)。 */
1248
+ const DROPDOWN_OPTION_SELECTOR = GENERIC.dropdownOptionEnumeration;
1081
1249
  /** 读取 PNG 文件头(IHDR)中的宽高,不解码像素(导出供测试)。 */
1082
1250
  function pngSize(buf) {
1083
1251
  if (buf.length < 24 || buf.readUInt32BE(0) !== 2303741511) throw new Error("not a PNG file");
@@ -1212,10 +1380,216 @@ function workspaceFilePath(cwd, file) {
1212
1380
  if (p !== root && !p.startsWith(root + "/") && !p.startsWith(root + "\\")) throw new Error(`文件路径越出工作区: ${file}`);
1213
1381
  return p;
1214
1382
  }
1383
+ const AsyncFunctionConstructor = Object.getPrototypeOf(async function() {}).constructor;
1384
+ /** 编译校验(只编译不执行):语法合法返回 true。 */
1385
+ function compiles(body) {
1386
+ try {
1387
+ new AsyncFunctionConstructor(body);
1388
+ return true;
1389
+ } catch {
1390
+ return false;
1391
+ }
1392
+ }
1393
+ /** `/` 出现在这些字符(或关键字)之后按正则字面量处理,否则按除号。 */
1394
+ const REGEX_ALLOWED_AFTER_CHARS = /* @__PURE__ */ new Set("([{,;=:?&|!%^~*+<>-/");
1395
+ const REGEX_ALLOWED_AFTER_WORDS = /* @__PURE__ */ new Set([
1396
+ "return",
1397
+ "typeof",
1398
+ "instanceof",
1399
+ "in",
1400
+ "of",
1401
+ "new",
1402
+ "delete",
1403
+ "void",
1404
+ "throw",
1405
+ "case",
1406
+ "do",
1407
+ "else",
1408
+ "yield",
1409
+ "await"
1410
+ ]);
1411
+ /**
1412
+ * 词法扫描(为 ② 服务,fail-safe):定位最后一个「顶层显式 ;」的结束位置,
1413
+ * 并检测顶层 return 关键字(有则说明调用方自己管理返回,不自动改写)。
1414
+ * 顶层 = 不在字符串/模板/注释/正则内,且 ()[]{} 合并深度为 0(含模板 ${} 插值内)。
1415
+ */
1416
+ function scanTopLevel(script) {
1417
+ let lastSemicolonEnd = null;
1418
+ let topLevelReturn = false;
1419
+ let state = "code";
1420
+ let depth = 0;
1421
+ const tplDepths = [];
1422
+ let prevSig = "";
1423
+ let prevWord = "";
1424
+ const isWordChar = (c) => /[A-Za-z0-9_$]/.test(c);
1425
+ for (let i = 0; i < script.length; i++) {
1426
+ const ch = script[i];
1427
+ switch (state) {
1428
+ case "line":
1429
+ if (ch === "\n") state = "code";
1430
+ continue;
1431
+ case "block":
1432
+ if (ch === "*" && script[i + 1] === "/") {
1433
+ state = "code";
1434
+ i++;
1435
+ }
1436
+ continue;
1437
+ case "sq":
1438
+ case "dq":
1439
+ if (ch === "\\") i++;
1440
+ else if (state === "sq" && ch === "'" || state === "dq" && ch === "\"") state = "code";
1441
+ continue;
1442
+ case "tpl":
1443
+ if (ch === "\\") i++;
1444
+ else if (ch === "`") {
1445
+ state = "code";
1446
+ prevSig = "`";
1447
+ prevWord = "";
1448
+ } else if (ch === "$" && script[i + 1] === "{") {
1449
+ tplDepths.push(depth);
1450
+ state = "code";
1451
+ i++;
1452
+ }
1453
+ continue;
1454
+ case "regex":
1455
+ if (ch === "\\") i++;
1456
+ else if (ch === "[") state = "regexClass";
1457
+ else if (ch === "/") {
1458
+ state = "code";
1459
+ prevSig = "/";
1460
+ prevWord = "";
1461
+ }
1462
+ continue;
1463
+ case "regexClass":
1464
+ if (ch === "\\") i++;
1465
+ else if (ch === "]") state = "regex";
1466
+ continue;
1467
+ default:
1468
+ if (ch === "/" && script[i + 1] === "/") {
1469
+ state = "line";
1470
+ i++;
1471
+ continue;
1472
+ }
1473
+ if (ch === "/" && script[i + 1] === "*") {
1474
+ state = "block";
1475
+ i++;
1476
+ continue;
1477
+ }
1478
+ if (ch === "/") {
1479
+ if (prevSig === "" || REGEX_ALLOWED_AFTER_CHARS.has(prevSig) || REGEX_ALLOWED_AFTER_WORDS.has(prevWord)) state = "regex";
1480
+ else {
1481
+ prevSig = "/";
1482
+ prevWord = "";
1483
+ }
1484
+ continue;
1485
+ }
1486
+ if (ch === "'" || ch === "\"") {
1487
+ state = ch === "'" ? "sq" : "dq";
1488
+ prevSig = ch;
1489
+ prevWord = "";
1490
+ continue;
1491
+ }
1492
+ if (ch === "`") {
1493
+ state = "tpl";
1494
+ prevSig = ch;
1495
+ prevWord = "";
1496
+ continue;
1497
+ }
1498
+ if (ch === "(" || ch === "[" || ch === "{") {
1499
+ depth++;
1500
+ prevSig = ch;
1501
+ prevWord = "";
1502
+ continue;
1503
+ }
1504
+ if (ch === ")" || ch === "]") {
1505
+ depth = Math.max(0, depth - 1);
1506
+ prevSig = ch;
1507
+ prevWord = "";
1508
+ continue;
1509
+ }
1510
+ if (ch === "}") {
1511
+ if (tplDepths.length > 0 && depth === tplDepths[tplDepths.length - 1]) {
1512
+ tplDepths.pop();
1513
+ state = "tpl";
1514
+ } else depth = Math.max(0, depth - 1);
1515
+ prevSig = ch;
1516
+ prevWord = "";
1517
+ continue;
1518
+ }
1519
+ if (ch === ";") {
1520
+ if (depth === 0 && tplDepths.length === 0) lastSemicolonEnd = i + 1;
1521
+ prevSig = ch;
1522
+ prevWord = "";
1523
+ continue;
1524
+ }
1525
+ if (isWordChar(ch)) {
1526
+ let j = i;
1527
+ while (j < script.length && isWordChar(script[j])) j++;
1528
+ const word = script.slice(i, j);
1529
+ if (depth === 0 && tplDepths.length === 0 && word === "return" && prevSig !== "." && !isWordChar(prevSig)) topLevelReturn = true;
1530
+ prevWord = word;
1531
+ prevSig = script[j - 1];
1532
+ i = j - 1;
1533
+ continue;
1534
+ }
1535
+ if (!/\s/.test(ch)) {
1536
+ prevSig = ch;
1537
+ prevWord = "";
1538
+ }
1539
+ continue;
1540
+ }
1541
+ }
1542
+ return {
1543
+ lastSemicolonEnd,
1544
+ topLevelReturn
1545
+ };
1546
+ }
1547
+ /**
1548
+ * 把 eval 脚本包装成可直接交给 page.evaluate 的代码串,并标明实际执行形态。
1549
+ * 语法非法时在 Node 侧抛出引擎原始错误(消息与页内 V8 同源,提前暴露)。
1550
+ */
1551
+ function buildEvalScript(script) {
1552
+ if (compiles(`return (\n${script}\n)`)) return {
1553
+ code: `(async () => (\n${script}\n))()`,
1554
+ mode: "expression"
1555
+ };
1556
+ try {
1557
+ new AsyncFunctionConstructor(script);
1558
+ } catch (err) {
1559
+ throw new Error(`eval 脚本语法错误: ${err.message}`);
1560
+ }
1561
+ const { lastSemicolonEnd, topLevelReturn } = scanTopLevel(script);
1562
+ if (!topLevelReturn && lastSemicolonEnd !== null) {
1563
+ const head = script.slice(0, lastSemicolonEnd);
1564
+ const tail = script.slice(lastSemicolonEnd);
1565
+ if (tail.trim() !== "") {
1566
+ const candidate = `${head}\nreturn (\n${tail}\n);`;
1567
+ if (compiles(candidate)) return {
1568
+ code: `(async () => {\n${candidate}\n})()`,
1569
+ mode: "statements-auto-return"
1570
+ };
1571
+ }
1572
+ }
1573
+ return {
1574
+ code: `(async () => {\n${script}\n})()`,
1575
+ mode: "statements"
1576
+ };
1577
+ }
1215
1578
  /**
1216
1579
  * 规则化抽取:fields 定义抽取结构,next 选择器驱动翻页(maxPages 上限),
1217
1580
  * 行数超 maxRows 截断;format/file 控制序列化与落盘(json/csv/jsonl/markdown)。
1218
1581
  */
1582
+ async function assertExpectedUrl(page, expectUrl, action) {
1583
+ if (typeof expectUrl !== "string" || expectUrl === "") return;
1584
+ const current = page.url();
1585
+ let hit;
1586
+ try {
1587
+ hit = new RegExp(expectUrl).test(current);
1588
+ } catch {
1589
+ hit = current.includes(expectUrl);
1590
+ }
1591
+ if (!hit) throw new Error(`${action} 前置校验失败: expectUrl "${expectUrl}" 与当前页面不符(当前: ${current})——页面可能被切换/回退,请先 browser_snapshot 确认后再操作`);
1592
+ }
1219
1593
  //#endregion
1220
1594
  //#region lib/types/browser/primitives/dom.js
1221
1595
  /**
@@ -1233,9 +1607,9 @@ function extractElementLabel(el) {
1233
1607
  const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)).filter((n) => !!n).map((n) => (n.innerText || n.textContent || "").trim()).filter(Boolean).join(" ");
1234
1608
  if (text) return text;
1235
1609
  }
1236
- const formItem = el.closest("[class*='form-item'], [class*='form__item'], [role='group'], form, .form-group");
1610
+ const formItem = el.closest(GENERIC.formItemContainers);
1237
1611
  if (formItem) {
1238
- const labelEl = formItem.querySelector("[class*='form-item-label'], [class*='form-item__label'], [class*='label'], label");
1612
+ const labelEl = formItem.querySelector(GENERIC.formItemLabels);
1239
1613
  if (labelEl) {
1240
1614
  const txt = (labelEl.innerText || labelEl.textContent || "").trim();
1241
1615
  if (txt) return txt;
@@ -1430,29 +1804,63 @@ async function endChangeWatch(page, options = {}) {
1430
1804
  }
1431
1805
  //#endregion
1432
1806
  //#region lib/types/browser/primitives/dialog.js
1807
+ /** 标准层:WAI-ARIA 语义,任何库与原生实现通用。 */
1808
+ const ARIA_DIALOG_SELECTORS = [
1809
+ "[role='dialog']",
1810
+ "[role='alertdialog']",
1811
+ "[aria-modal='true']",
1812
+ "dialog[open]"
1813
+ ];
1814
+ /** display:none 内联样式预过滤(动画关闭中的弹窗/keep-alive 残留)。 */
1815
+ const hiddenGuard = (sel) => `${sel}:not([style*='display: none'])`;
1816
+ /** 精确层 = 标准层 + 库精确层(ui-library 注册表单一来源,新增 UI 库 = 追加 spec)。 */
1817
+ const DIALOG_PRECISE_SELECTORS = [...ARIA_DIALOG_SELECTORS, ...LIBRARY_DIALOG_CONTAINERS].map(hiddenGuard).join(", ");
1818
+ /** 通用模糊层(未注册库兜底):子串词 + :not() 排除子节点。 */
1819
+ const DIALOG_FUZZY_SELECTORS = composeDialogFuzzy();
1433
1820
  /**
1434
- * 通用活动弹窗/对话框/抽屉选择器列表
1435
- * 基于 W3C WAI-ARIA 规范 (role="dialog", aria-modal="true") 与现代 Web 通用命名规范
1821
+ * 历史并集 = 精确层 + 模糊层。semantic.ts 弹窗上下文检测与 getActiveDialog 的
1822
+ * domOrder 兜底消费(行为与抽取前的并集等价)。
1436
1823
  */
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(", ");
1824
+ const ACTIVE_DIALOG_SELECTORS = [DIALOG_PRECISE_SELECTORS, DIALOG_FUZZY_SELECTORS].join(", ");
1450
1825
  /**
1451
- * 获取当前页面上处于活动状态的最顶层弹窗容器 (Locator)
1826
+ * 获取当前页面上处于活动状态的最顶层弹窗容器;无弹窗返回 null。
1827
+ * ① 快速通道——并集零命中(绝大多数无弹窗的工具调用)直接返回 null,与旧行为
1828
+ * 「空 locator + 下游 count 0」等价,零额外 evaluate 往返;
1829
+ * ② 有弹窗时走分层选择器 + 页内 z-index/可见性仲裁(walker pickTopDialog,
1830
+ * 页内逻辑单一来源),单次 round-trip;
1831
+ * ③ evaluate 失败(极端 CSP/销毁竞态)或复核计数为 0 时回退文档序 last()
1832
+ * 旧行为并标 via: "domOrder",不阻断主流程。
1452
1833
  */
1453
- function getActiveDialog(page) {
1834
+ async function getActiveDialog(page) {
1835
+ try {
1836
+ const unionLoc = page.locator(ACTIVE_DIALOG_SELECTORS);
1837
+ if (typeof unionLoc.count === "function") {
1838
+ if (await withTimeout(unionLoc.count(), 800, "弹窗快速探测").catch(() => 0) === 0) return null;
1839
+ }
1840
+ } catch {}
1841
+ try {
1842
+ const pick = await withTimeout(page.evaluate(pickTopDialog, {
1843
+ precise: DIALOG_PRECISE_SELECTORS,
1844
+ fuzzy: DIALOG_FUZZY_SELECTORS
1845
+ }), 1500, "活动弹窗仲裁");
1846
+ if (pick !== null && typeof pick === "object" && typeof pick.selector === "string" && pick.selector !== "") {
1847
+ const hit = pick;
1848
+ const loc = page.locator(hit.selector);
1849
+ if (typeof loc.count === "function") {
1850
+ if (await withTimeout(loc.count(), 800, "活动弹窗复核").catch(() => 0) > 0) return {
1851
+ locator: typeof loc.first === "function" ? loc.first() : loc,
1852
+ via: "zIndex",
1853
+ z: hit.z,
1854
+ tier: hit.tier
1855
+ };
1856
+ }
1857
+ }
1858
+ } catch {}
1454
1859
  const dialogLoc = page.locator(ACTIVE_DIALOG_SELECTORS);
1455
- return typeof dialogLoc.last === "function" ? dialogLoc.last() : dialogLoc.first();
1860
+ return {
1861
+ locator: typeof dialogLoc.last === "function" ? dialogLoc.last() : dialogLoc.first(),
1862
+ via: "domOrder"
1863
+ };
1456
1864
  }
1457
1865
  //#endregion
1458
1866
  //#region lib/types/browser/primitives/select.js
@@ -1471,14 +1879,14 @@ async function executeUniversalSelect(ctx) {
1471
1879
  throw new Error(`原生下拉无选项 "${want}"; 可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
1472
1880
  }
1473
1881
  let resolvedTarget = target;
1474
- if (await withTimeout(target.evaluate((el) => {
1882
+ if (await withTimeout(target.evaluate((el, hosts) => {
1475
1883
  if (el.tagName.toLowerCase() === "label") return true;
1476
1884
  const cls = typeof el.className === "string" ? el.className : "";
1477
1885
  if (/(?:form-item|form__item|form-group)/i.test(cls)) return true;
1478
- if (el.closest("label, [class*='form-item'], [class*='form__item'], [class*='form-group']")) return !el.matches("[role='combobox'], select, [class*='select-selector']");
1886
+ if (el.closest(hosts)) return !el.matches("[role='combobox'], select, [class*='select-selector']");
1479
1887
  if (!el.matches("[role='combobox'], select, [class*='select'], [class*='selector']")) return true;
1480
1888
  return false;
1481
- }), 1500, "探测 label 或非标文本容器").catch(() => false)) {
1889
+ }, GENERIC.formItemHosts), 1500, "探测 label 或非标文本容器").catch(() => false)) {
1482
1890
  const descendantSelect = target.locator("xpath=.//*[@role='combobox' or self::select or contains(@class, 'select-selector') or contains(@class, 'select')][not(self::label)]").first();
1483
1891
  if (await descendantSelect.count().catch(() => 0) > 0) resolvedTarget = descendantSelect;
1484
1892
  else {
@@ -1503,15 +1911,15 @@ async function executeUniversalSelect(ctx) {
1503
1911
  const hostLocator = resolvedTarget.locator("xpath=ancestor-or-self::*[@role='combobox' or contains(@class, 'select') or contains(@class, 'selector')][last()]");
1504
1912
  const actualTarget = await hostLocator.count().catch(() => 0) > 0 ? hostLocator.first() : resolvedTarget;
1505
1913
  if (clear) try {
1506
- const clearBtn = actualTarget.locator("[class*='clear'], [aria-label*='clear']").first();
1914
+ const clearBtn = actualTarget.locator(GENERIC.dropdownClearButtons).first();
1507
1915
  if (await clearBtn.count().catch(() => 0) > 0 && await clearBtn.isVisible().catch(() => false)) await clearBtn.click({ timeout: 1e3 }).catch(() => {});
1508
1916
  else {
1509
- const removeBtns = actualTarget.locator("[class*='remove'], [class*='close'], [aria-label*='close']");
1917
+ const removeBtns = actualTarget.locator(GENERIC.dropdownRemoveButtons);
1510
1918
  const removeCount = await removeBtns.count().catch(() => 0);
1511
1919
  for (let i = 0; i < removeCount; i++) await removeBtns.nth(0).click({ timeout: 500 }).catch(() => {});
1512
1920
  }
1513
1921
  } catch {}
1514
- if (!await withTimeout(actualTarget.evaluate((el) => {
1922
+ const probeExpanded = () => withTimeout(actualTarget.evaluate((el) => {
1515
1923
  if (el.getAttribute("aria-expanded") === "true") return true;
1516
1924
  const host = el.closest("[role='combobox'], [class*='select']") || el;
1517
1925
  if (host && host.getAttribute("aria-expanded") === "true") return true;
@@ -1524,8 +1932,11 @@ async function executeUniversalSelect(ctx) {
1524
1932
  if (dd && dd.offsetParent !== null) return true;
1525
1933
  }
1526
1934
  return false;
1527
- }), 2e3, "检查下拉展开状态").catch(() => false)) {
1528
- const triggerChild = actualTarget.locator("[class*='selector'], [role='combobox']").first();
1935
+ }), 2e3, "检查下拉展开状态").catch(() => false);
1936
+ const isExpanded = await probeExpanded();
1937
+ let expandSignalMissed = false;
1938
+ if (!isExpanded) {
1939
+ const triggerChild = actualTarget.locator(GENERIC.dropdownTriggerParts).first();
1529
1940
  let clicked = false;
1530
1941
  if (await triggerChild.count().catch(() => 0) > 0) {
1531
1942
  await triggerChild.click({ timeout: 2e3 }).catch(async () => {
@@ -1543,7 +1954,9 @@ async function executeUniversalSelect(ctx) {
1543
1954
  });
1544
1955
  });
1545
1956
  await page.waitForTimeout(120);
1957
+ expandSignalMissed = !await probeExpanded();
1546
1958
  }
1959
+ const optionWaitMs = expandSignalMissed ? 1500 : Math.min(timeout, 5e3);
1547
1960
  const controlsId = await withTimeout(actualTarget.evaluate((el) => {
1548
1961
  const direct = el.getAttribute("aria-controls") || el.getAttribute("aria-owns");
1549
1962
  if (direct) return direct;
@@ -1551,8 +1964,8 @@ async function executeUniversalSelect(ctx) {
1551
1964
  const inner = host?.querySelector("input[role='combobox'], [aria-controls], [aria-owns]");
1552
1965
  return inner?.getAttribute("aria-controls") || inner?.getAttribute("aria-owns") || host?.getAttribute("aria-controls") || "";
1553
1966
  }), 2e3, "解析下拉关联 controlsId").catch(() => "");
1554
- const optionSelector = "[role='option']:not([style*='height: 0px']):not([class*='empty']), [class*='item-option']:not([class*='empty']), [class*='select-dropdown'] li:not([class*='empty'])";
1555
- const dropdownSelector = "[role='listbox']:not([style*='display: none']), [class*='select-dropdown']:not([style*='display: none']):not([class*='hidden']), [class*='popper']:not([style*='display: none'])";
1967
+ const optionSelector = ["[role='option']:not([style*='height: 0px']):not([class*='empty'])", ...LIBRARY_DROPDOWN_OPTIONS].join(", ");
1968
+ const dropdownSelector = ["[role='listbox']:not([style*='display: none'])", ...LIBRARY_DROPDOWN_CONTAINERS].join(", ");
1556
1969
  let options;
1557
1970
  let targetDropdown = null;
1558
1971
  if (controlsId) {
@@ -1570,7 +1983,7 @@ async function executeUniversalSelect(ctx) {
1570
1983
  }
1571
1984
  await options.first().waitFor({
1572
1985
  state: "visible",
1573
- timeout: Math.min(timeout, 5e3)
1986
+ timeout: optionWaitMs
1574
1987
  }).catch(() => {});
1575
1988
  const visibleTexts = (await options.evaluateAll((els) => els.map((el) => {
1576
1989
  return {
@@ -1580,10 +1993,12 @@ async function executeUniversalSelect(ctx) {
1580
1993
  };
1581
1994
  })).catch(() => [])).map((e) => e.text || e.title || e.aria).filter(Boolean);
1582
1995
  if (visibleTexts.length === 0) {
1583
- const isEmpty = targetDropdown !== null ? await targetDropdown.locator("[class*='empty'], [class*='no-data'], [role='status'], [class*='item-empty']").first().isVisible().catch(() => false) : false;
1996
+ const emptySelector = GENERIC.emptyStates;
1997
+ const isEmpty = targetDropdown !== null ? await targetDropdown.locator(emptySelector).first().isVisible().catch(() => false) : false;
1584
1998
  await actualTarget.click({ timeout: 1e3 }).catch(() => {});
1585
- if (isEmpty) throw new Error(`下拉浮层当前无选项(数据为空态/无可选数据): ${label}`);
1586
- throw new Error(`点击 ${label} 后未出现下拉选项(选项列表为空态)`);
1999
+ const attribution = expandSignalMissed ? ";触发器点击疑似未生效(展开信号缺失,可能被透明遮罩拦截,建议 browser_snapshot 核实或配合 click effect 验证)" : "";
2000
+ if (isEmpty) throw new Error(`下拉浮层当前无选项(数据为空态/无可选数据): ${label}${attribution}`);
2001
+ throw new Error(`点击 ${label} 后未出现下拉选项(选项列表为空态)${attribution}`);
1587
2002
  }
1588
2003
  const wantStripped = want.replace(/\s+/g, "");
1589
2004
  let index = visibleTexts.findIndex((t) => t === want);
@@ -1729,7 +2144,7 @@ async function fillFormFieldHelper(page, field, scope) {
1729
2144
  const root = scope ? page.locator(scope) : page;
1730
2145
  const { key, value } = field;
1731
2146
  try {
1732
- const formItems = root.locator("[class*='form-item'], [class*='form__item'], [role='group'], .form-group");
2147
+ const formItems = root.locator(GENERIC.formItemContainers);
1733
2148
  const count = await formItems.count().catch(() => 0);
1734
2149
  for (let i = 0; i < count; i++) {
1735
2150
  const item = formItems.nth(i);
@@ -1791,6 +2206,45 @@ async function fillFormFieldHelper(page, field, scope) {
1791
2206
  };
1792
2207
  }
1793
2208
  //#endregion
2209
+ //#region lib/types/browser/primitives/drag.js
2210
+ /**
2211
+ * 核心:在两个已解析的 Locator 之间执行拖拽。
2212
+ * 优先 Playwright dragTo(覆盖 HTML5 DnD 与多数组件库);
2213
+ * 失败或调用方指定 preferMouse(如跨 Frame,dragTo 的已知限制)时
2214
+ * 降级为物理鼠标序列。返回实际使用的通道。失败抛错(带可定位的诊断信息)。
2215
+ */
2216
+ async function dragAndDropLocators(page, source, target, opts = {}) {
2217
+ const timeoutMs = opts.timeoutMs ?? 3e3;
2218
+ if (!opts.preferMouse) try {
2219
+ if (typeof source.dragTo === "function") {
2220
+ await source.dragTo(target, { timeout: timeoutMs });
2221
+ return "dragTo";
2222
+ }
2223
+ } catch {}
2224
+ let srcErr = null;
2225
+ const srcBox = await source.boundingBox({ timeout: timeoutMs }).catch((e) => {
2226
+ srcErr = e;
2227
+ return null;
2228
+ });
2229
+ if (!srcBox) throw new Error(`drag 失败: 源元素不可见或未命中(等待 ${timeoutMs}ms)${srcErr instanceof Error ? `: ${srcErr.message}` : ""}`);
2230
+ let tgtErr = null;
2231
+ const tgtBox = await target.boundingBox({ timeout: timeoutMs }).catch((e) => {
2232
+ tgtErr = e;
2233
+ return null;
2234
+ });
2235
+ if (!tgtBox) throw new Error(`drag 失败: 目标元素不可见或未命中(等待 ${timeoutMs}ms)${tgtErr instanceof Error ? `: ${tgtErr.message}` : ""}`);
2236
+ const viewport = typeof page.viewportSize === "function" ? page.viewportSize() : null;
2237
+ 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);
2238
+ if (outsideViewport && typeof target.scrollIntoViewIfNeeded === "function") await target.scrollIntoViewIfNeeded({ timeout: timeoutMs }).catch(() => {});
2239
+ const sx = (outsideViewport ? await source.boundingBox({ timeout: timeoutMs }).catch(() => null) : null) ?? srcBox;
2240
+ const tx = (outsideViewport ? await target.boundingBox({ timeout: timeoutMs }).catch(() => null) : null) ?? tgtBox;
2241
+ await page.mouse.move(sx.x + sx.width / 2, sx.y + sx.height / 2);
2242
+ await page.mouse.down();
2243
+ await page.mouse.move(tx.x + tx.width / 2, tx.y + tx.height / 2, { steps: 8 });
2244
+ await page.mouse.up();
2245
+ return "mouse";
2246
+ }
2247
+ //#endregion
1794
2248
  //#region lib/types/browser/session/semantic.js
1795
2249
  /**
1796
2250
  * 页内语义扫描:按 query 打分收集可交互元素(弹窗内优先、可见优先、高分优先)。
@@ -2149,6 +2603,37 @@ const failureContextProbe = injectWalker(() => {
2149
2603
  hasShadow
2150
2604
  };
2151
2605
  });
2606
+ /**
2607
+ * selector 零命中时的相似属性探针(best-effort):提取首个属性片段(或 #id)
2608
+ * 做包含匹配计数——直接暴露「锚点属性全失效」与「组合选择器其他部分写错」两种病因。
2609
+ * 纯提示用途,失败/无属性片段返回空串。
2610
+ */
2611
+ const similarAttrProbe = injectWalker((rawSelector) => {
2612
+ let attr = "";
2613
+ let val = "";
2614
+ const m = rawSelector.match(/\[([\w-]+)(?:\^|\$|\*|~)?=['"]([^'"]+)['"]\]/);
2615
+ if (m !== null) {
2616
+ attr = m[1] ?? "";
2617
+ val = m[2] ?? "";
2618
+ } else {
2619
+ const idm = rawSelector.match(/^#([\w-]+)/);
2620
+ if (idm !== null) {
2621
+ attr = "id";
2622
+ val = idm[1] ?? "";
2623
+ }
2624
+ }
2625
+ if (attr === "" || val === "") return "";
2626
+ try {
2627
+ const safe = val.replace(/["\\]/g, "");
2628
+ if (safe === "") return "";
2629
+ const hits = Array.from(document.querySelectorAll(`[${attr}*="${safe}"]`)).slice(0, 50);
2630
+ if (hits.length === 0) return `页面不存在任何 ${attr} 含 "${safe}" 的元素(锚点属性可能已失效或页面已切换)`;
2631
+ const samples = hits.slice(0, 3).map((el) => el.getAttribute(attr) || "").filter((s) => s !== "").join(" | ").slice(0, 120);
2632
+ return `页面存在 ${hits.length} 个 ${attr} 含 "${safe}" 的元素(组合选择器的其他部分可能写错)${samples !== "" ? `,如: ${samples}` : ""}`;
2633
+ } catch {
2634
+ return "";
2635
+ }
2636
+ });
2152
2637
  /** ref 坐标兜底的名称相似判定:去空白互相包含,或 LCS 占比 ≥ 0.6。 */
2153
2638
  function nameSimilar(a, b) {
2154
2639
  if (a === "" || b === "") return true;
@@ -2230,9 +2715,9 @@ async function resolveTargetLocator(page, args, opts = {}) {
2230
2715
  };
2231
2716
  }
2232
2717
  if (!isIFrame) try {
2233
- const activeDialog = getActiveDialog(page);
2234
- if (activeDialog && typeof activeDialog.getByRole === "function") {
2235
- const inDialog = entry.name !== "" ? activeDialog.getByRole(entry.role, { name: buildFlexibleTextPattern(entry.name) }).first() : activeDialog.getByRole(entry.role).first();
2718
+ const dlg = (await getActiveDialog(page))?.locator;
2719
+ if (dlg && typeof dlg.getByRole === "function") {
2720
+ const inDialog = entry.name !== "" ? dlg.getByRole(entry.role, { name: buildFlexibleTextPattern(entry.name) }).first() : dlg.getByRole(entry.role).first();
2236
2721
  if (await withTimeout(inDialog.count(), 1e3, "弹窗内 role 查找").catch(() => 0) > 0) return {
2237
2722
  locator: inDialog,
2238
2723
  container,
@@ -2354,10 +2839,29 @@ async function resolveTargetLocator(page, args, opts = {}) {
2354
2839
  }
2355
2840
  const what = args.what;
2356
2841
  if (typeof what === "string" && what.trim() !== "" && args.selector === void 0 && args.ref === void 0 && args.mark === void 0 && rowPattern === void 0) {
2842
+ const navSeqBefore = pageNavSeq(page);
2843
+ let urlBefore = null;
2844
+ try {
2845
+ urlBefore = page.url();
2846
+ } catch {}
2357
2847
  const res = await resolveSemanticTarget(page, what, {
2358
2848
  scope: typeof args.scope === "string" && args.scope !== "" ? args.scope : void 0,
2359
2849
  preferredRoles: opts.preferredRoles
2360
2850
  });
2851
+ let urlAfter = urlBefore;
2852
+ try {
2853
+ urlAfter = page.url();
2854
+ } catch {}
2855
+ if (pageNavSeq(page) !== navSeqBefore || urlAfter !== urlBefore) {
2856
+ const raceErr = /* @__PURE__ */ new Error(`页面在语义定位过程中发生变化(${urlBefore} → ${page.url()}),本次扫描结果已作废;请重试或重新 browser_snapshot`);
2857
+ raceErr.locationFailure = {
2858
+ phase: "navRace",
2859
+ candidates: 0,
2860
+ dialogActive: false,
2861
+ hasShadow: false
2862
+ };
2863
+ throw raceErr;
2864
+ }
2361
2865
  if (!("candidates" in res)) return res;
2362
2866
  for (const role of [
2363
2867
  "button",
@@ -2414,6 +2918,14 @@ async function resolveTargetLocator(page, args, opts = {}) {
2414
2918
  ...ctx
2415
2919
  };
2416
2920
  } catch {}
2921
+ const navDelta = navSinceStable(page);
2922
+ if (navDelta >= 1) {
2923
+ let route = "";
2924
+ try {
2925
+ route = page.url().slice(0, 80);
2926
+ } catch {}
2927
+ err.message = `${err.message};页面自上次成功操作后已发生 ${navDelta} 次导航,what 目标可能在另一页面${route !== "" ? `(当前: ${route})` : ""}`;
2928
+ }
2417
2929
  throw err;
2418
2930
  }
2419
2931
  if (args.selector === void 0) throw new Error("需要 selector 或 ref 之一 (或指定 row 与 action 进行表格行级定界,或用 what 语义定位)");
@@ -2431,9 +2943,9 @@ async function resolveTargetLocator(page, args, opts = {}) {
2431
2943
  }
2432
2944
  }
2433
2945
  try {
2434
- const activeDialog = getActiveDialog(page);
2435
- if (activeDialog && typeof activeDialog.locator === "function") {
2436
- const inDialog = activeDialog.locator(rawSelector);
2946
+ const dlg = (await getActiveDialog(page))?.locator;
2947
+ if (dlg && typeof dlg.locator === "function") {
2948
+ const inDialog = dlg.locator(rawSelector);
2437
2949
  if (typeof inDialog.count === "function") {
2438
2950
  const inDialogCount = await withTimeout(inDialog.count(), 1e3, "弹窗内 selector 探测").catch(() => 0);
2439
2951
  if (inDialogCount > 0) {
@@ -2454,7 +2966,43 @@ async function resolveTargetLocator(page, args, opts = {}) {
2454
2966
  let visibleCount;
2455
2967
  let target = typeof baseLocator.first === "function" ? baseLocator.first() : baseLocator;
2456
2968
  if (typeof baseLocator.count === "function") {
2457
- totalCount = await withTimeout(baseLocator.count(), 2e3, "selector 计数").catch(() => 0);
2969
+ try {
2970
+ totalCount = await withTimeout(baseLocator.count(), 2e3, "selector 计数");
2971
+ } catch {
2972
+ const err = /* @__PURE__ */ new Error(`selector 计数超时: ${rawSelector}(页面可能正忙或 DOM 过大,可重试;若持续超时建议 browser_snapshot 核实页面状态)`);
2973
+ err.locationFailure = {
2974
+ phase: "countTimeout",
2975
+ candidates: 0,
2976
+ dialogActive: false,
2977
+ hasShadow: false
2978
+ };
2979
+ throw err;
2980
+ }
2981
+ if (totalCount === 0) {
2982
+ const zeroMatchErr = /* @__PURE__ */ new Error(`selector 未命中: ${rawSelector}(元素未渲染/选择器写错/页面已切换;可用 browser_elements 探查表单结构、browser_wait_for 等元素出现,或 browser_snapshot 取 ref)`);
2983
+ zeroMatchErr.locationFailure = {
2984
+ phase: "zeroMatch",
2985
+ candidates: 0,
2986
+ dialogActive: false,
2987
+ hasShadow: false
2988
+ };
2989
+ try {
2990
+ const ctx = await withTimeout(page.evaluate(failureContextProbe, void 0), 1500, "失败特征探测");
2991
+ if (ctx !== null && typeof ctx === "object") zeroMatchErr.locationFailure = {
2992
+ phase: "zeroMatch",
2993
+ candidates: 0,
2994
+ ...ctx
2995
+ };
2996
+ } catch {}
2997
+ try {
2998
+ const hint = await withTimeout(page.evaluate(similarAttrProbe, rawSelector), 1500, "相似属性探测");
2999
+ if (typeof hint === "string" && hint !== "") {
3000
+ zeroMatchErr.similarHint = hint;
3001
+ zeroMatchErr.message = `${zeroMatchErr.message};${hint}`;
3002
+ }
3003
+ } catch {}
3004
+ throw zeroMatchErr;
3005
+ }
2458
3006
  if (totalCount > 0 && typeof baseLocator.locator === "function") {
2459
3007
  const visibleLocator = baseLocator.locator("visible=true");
2460
3008
  if (typeof visibleLocator.count === "function") {
@@ -2869,6 +3417,8 @@ const DOM_COMPAT_SCRIPT = `
2869
3417
  };
2870
3418
  })();
2871
3419
  `;
3420
+ /** 最近一次工具触碰时间戳(getPage 打点):外部导航判定基准。 */
3421
+ let lastToolTouchAt = 0;
2872
3422
  function attachPage(page, config) {
2873
3423
  const timeoutMs = config.timeoutMs ?? 3e4;
2874
3424
  page.setDefaultTimeout(timeoutMs);
@@ -2892,6 +3442,7 @@ function attachPage(page, config) {
2892
3442
  const url = frame.url();
2893
3443
  if (!url || url === "about:blank") return;
2894
3444
  recordNav(profile, url);
3445
+ if (Date.now() - lastToolTouchAt > 1e4) noteExternalNav(page, url);
2895
3446
  });
2896
3447
  page.on("load", () => {
2897
3448
  const list = historyOf(profile);
@@ -3117,6 +3668,7 @@ async function newPage(config) {
3117
3668
  }
3118
3669
  /** 返回当前活动页面(没有则新建),并保证浏览器已启动。 */
3119
3670
  async function getPage(config) {
3671
+ lastToolTouchAt = Date.now();
3120
3672
  const state = stateOf();
3121
3673
  if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
3122
3674
  pruneClosedPages(state);
@@ -3180,6 +3732,7 @@ async function closeBrowser() {
3180
3732
  }
3181
3733
  /** Best-effort current page identity, safe when nothing is open.title 有界防页面忙时挂起。 */
3182
3734
  async function pageIdentity(page) {
3735
+ markStableNav(page);
3183
3736
  try {
3184
3737
  const title = await new Promise((resolve, reject) => {
3185
3738
  const t = setTimeout(() => reject(/* @__PURE__ */ new Error("title timeout")), 2e3);
@@ -3775,15 +4328,20 @@ async function snapshotTool(config, args) {
3775
4328
  const isDialogScope = /modal|dialog|drawer/i.test(scope);
3776
4329
  let hint = isDialogScope ? "。目标容器可能已消失(弹窗/抽屉已关闭、已提交或页面已导航)" : "";
3777
4330
  if (isDialogScope && typeof page.locator === "function") try {
3778
- const activeDialogLoc = getActiveDialog(page);
3779
- if ((typeof activeDialogLoc?.count === "function" ? await withTimeout(activeDialogLoc.count(), 1e3, "活动弹窗探测").catch(() => 0) : 0) > 0 && typeof activeDialogLoc.first === "function") {
4331
+ const activeDialogLoc = (await getActiveDialog(page))?.locator;
4332
+ const count = activeDialogLoc !== void 0 && typeof activeDialogLoc.count === "function" ? await withTimeout(activeDialogLoc.count(), 1e3, "活动弹窗探测").catch(() => 0) : 0;
4333
+ if (activeDialogLoc !== void 0 && count > 0 && typeof activeDialogLoc.first === "function") {
3780
4334
  const alternate = await withTimeout(activeDialogLoc.first().evaluate((el) => {
3781
- if (el.getAttribute("role") === "dialog") return "[role='dialog']";
4335
+ const out = [];
4336
+ const role = el.getAttribute("role");
4337
+ if (role === "dialog" || role === "alertdialog") out.push(`[role='${role}']`);
3782
4338
  const cls = el.className;
3783
4339
  if (typeof cls === "string" && cls.trim()) {
3784
- const parts = cls.trim().split(/\s+/);
3785
- return `.${parts.find((c) => /modal|dialog|drawer/i.test(c)) || parts[0]}`;
4340
+ const tokens = cls.trim().split(/\s+/);
4341
+ const relevant = tokens.find((c) => /modal|dialog|drawer/i.test(c)) || tokens[0];
4342
+ out.push(`.${relevant}`);
3786
4343
  }
4344
+ if (out.length > 0) return out.join("");
3787
4345
  return el.tagName.toLowerCase();
3788
4346
  }), 1e3, "解析活动弹窗标识").catch(() => null);
3789
4347
  if (alternate) hint += ` (检测到当前页面存在「${alternate}」容器,可能混淆了弹窗与抽屉类型)`;
@@ -4103,7 +4661,7 @@ async function somFallbackShot(config, exec) {
4103
4661
  async function elementsTool(config, args) {
4104
4662
  const page = await getPage(config);
4105
4663
  const max = Math.min(Math.max(Number(args.max ?? 60) || 60, 1), 200);
4106
- const data = await page.evaluate(([maxN, optionSelector]) => {
4664
+ const data = await page.evaluate(([maxN, optionSelector, formItemSel, formLabelSel]) => {
4107
4665
  /** 提取元素的关联 label:aria-label → label[for] → form-item 标签(antd/element)。 */
4108
4666
  function labelOf(el) {
4109
4667
  const aria = el.getAttribute("aria-label");
@@ -4112,7 +4670,7 @@ async function elementsTool(config, args) {
4112
4670
  const text = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
4113
4671
  if (text) return text;
4114
4672
  }
4115
- const formLabel = el.closest("[class*='-form-item'], [class*='__form-item'], form, .form-group")?.querySelector("[class*='label'], label")?.textContent?.trim();
4673
+ const formLabel = el.closest(formItemSel)?.querySelector(formLabelSel)?.textContent?.trim();
4116
4674
  if (formLabel) return formLabel.replace(/[*::]\s*$/, "");
4117
4675
  return "";
4118
4676
  }
@@ -4144,7 +4702,12 @@ async function elementsTool(config, args) {
4144
4702
  elements: out,
4145
4703
  dropdownOptions: Array.from(document.querySelectorAll(optionSelector)).map((el) => (el.textContent ?? "").trim()).filter(Boolean).slice(0, 50)
4146
4704
  };
4147
- }, [max, DROPDOWN_OPTION_SELECTOR]);
4705
+ }, [
4706
+ max,
4707
+ DROPDOWN_OPTION_SELECTOR,
4708
+ GENERIC.formItemContainersStrict,
4709
+ GENERIC.formLabelSimple
4710
+ ]);
4148
4711
  return {
4149
4712
  count: data.elements.length,
4150
4713
  elements: data.elements,
@@ -4321,6 +4884,8 @@ async function enrichSemanticMiss(e, config, exec) {
4321
4884
  }
4322
4885
  async function clickTool(config, args, exec) {
4323
4886
  const page = await getPage(config);
4887
+ await assertExpectedUrl(page, args.expectUrl, "click");
4888
+ const extWarning = drainExternalNavWarning(page);
4324
4889
  let targetRes;
4325
4890
  try {
4326
4891
  targetRes = await resolveTargetLocator(page, args);
@@ -4423,6 +4988,7 @@ async function clickTool(config, args, exec) {
4423
4988
  clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
4424
4989
  effect,
4425
4990
  ...quiet ? { effectHint: "点击后未观察到任何 DOM/URL/网络变化,可能未生效(被遮挡/事件被吞/前置条件未满足);建议 browser_snapshot 核实或换目标" } : {},
4991
+ ...extWarning !== void 0 ? { warning: extWarning } : {},
4426
4992
  ...ambiguityFields(targetRes),
4427
4993
  ...targetRes.viaBridge === true ? { viaBridge: true } : {},
4428
4994
  ...await pageIdentity(page)
@@ -4430,6 +4996,7 @@ async function clickTool(config, args, exec) {
4430
4996
  }
4431
4997
  return {
4432
4998
  clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
4999
+ ...extWarning !== void 0 ? { warning: extWarning } : {},
4433
5000
  ...ambiguityFields(targetRes),
4434
5001
  ...targetRes.viaBridge === true ? { viaBridge: true } : {},
4435
5002
  ...await pageIdentity(page)
@@ -4437,6 +5004,8 @@ async function clickTool(config, args, exec) {
4437
5004
  }
4438
5005
  async function typeTool(config, args) {
4439
5006
  const page = await getPage(config);
5007
+ await assertExpectedUrl(page, args.expectUrl, "type");
5008
+ const extWarning = drainExternalNavWarning(page);
4440
5009
  const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
4441
5010
  "textbox",
4442
5011
  "searchbox",
@@ -4451,27 +5020,54 @@ async function typeTool(config, args) {
4451
5020
  const selector = String(args.ref ?? args.selector ?? "");
4452
5021
  if (await fillEditorHelper(page, selector, text)) return {
4453
5022
  typed: true,
4454
- editor: true,
5023
+ typedVia: "editor",
4455
5024
  selector,
4456
5025
  ...ambiguityFields(targetRes),
4457
5026
  ...await pageIdentity(page)
4458
5027
  };
4459
5028
  }
5029
+ let typedVia = "clicked";
5030
+ let focusedElement;
4460
5031
  if (args.clear === true) {
4461
5032
  if (isHuman) {
4462
5033
  await target.fill("");
4463
5034
  await humanType(page, text, { speed: config.humanizeSpeed });
4464
5035
  } else await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
5036
+ typedVia = "fill";
4465
5037
  } else {
4466
5038
  const ambiguous = args.strict === true || (targetRes.matchedCount ?? 0) > 1;
5039
+ let clickOk = false;
4467
5040
  try {
4468
5041
  await target.click({ timeout: Math.min(args.timeoutMs ?? config.timeoutMs, 5e3) });
5042
+ clickOk = true;
4469
5043
  } catch (e) {
4470
5044
  if (ambiguous) throw new Error(`type 定位目标存在歧义(命中 ${targetRes.matchedCount ?? "?"} 个元素)且首个不可交互,已中止;请用 scope 限定或 browser_snapshot 取 ref 消歧;原始错误: ${e instanceof Error ? e.message : String(e)}`);
4471
- await target.click({
4472
- timeout: 2e3,
4473
- force: true
4474
- }).catch(() => {});
5045
+ try {
5046
+ await target.click({
5047
+ timeout: 2e3,
5048
+ force: true
5049
+ });
5050
+ clickOk = true;
5051
+ } catch {}
5052
+ }
5053
+ if (!clickOk) {
5054
+ const probe = await withTimeout(target.evaluate((el) => {
5055
+ const ae = document.activeElement;
5056
+ if (ae === null) return {
5057
+ owns: false,
5058
+ desc: null
5059
+ };
5060
+ return {
5061
+ owns: ae === el || el.contains(ae),
5062
+ desc: ae.outerHTML.slice(0, 200)
5063
+ };
5064
+ }), 2e3, "焦点归属探测").catch(() => ({
5065
+ owns: false,
5066
+ desc: null
5067
+ }));
5068
+ focusedElement = probe.desc ?? void 0;
5069
+ if (!probe.owns) throw new Error(`type 目标不可点击且焦点不在目标上,已中止输入(避免盲打到错误输入框)。当前焦点: ${focusedElement ?? "(无法探测/无焦点)"};目标: ${String(args.ref ?? args.selector ?? args.what ?? "")}。建议 browser_snapshot 核实目标状态,或 browser_wait_for 等元素可交互`);
5070
+ typedVia = "focus-kept";
4475
5071
  }
4476
5072
  if (isHuman) await humanType(page, text, { speed: config.humanizeSpeed });
4477
5073
  else await page.keyboard.type(text, { delay: args.delayMs ?? 0 });
@@ -4479,6 +5075,9 @@ async function typeTool(config, args) {
4479
5075
  if (args.submit === true) await page.keyboard.press("Enter");
4480
5076
  return {
4481
5077
  typed: true,
5078
+ typedVia,
5079
+ ...focusedElement !== void 0 ? { focusedElement } : {},
5080
+ ...extWarning !== void 0 ? { warning: extWarning } : {},
4482
5081
  selector: String(args.ref ?? args.selector),
4483
5082
  submit: args.submit === true,
4484
5083
  ...ambiguityFields(targetRes),
@@ -4489,6 +5088,8 @@ async function pressTool(config, args) {
4489
5088
  const page = await getPage(config);
4490
5089
  const key = String(args.key);
4491
5090
  let warning;
5091
+ const extWarning = drainExternalNavWarning(page);
5092
+ if (extWarning !== void 0) warning = warning !== void 0 ? `${warning};${extWarning}` : extWarning;
4492
5093
  if (key === "Escape" && args.selector === void 0 && args.ref === void 0) {
4493
5094
  const checkState = await page.evaluate(() => {
4494
5095
  return {
@@ -4532,6 +5133,8 @@ async function pressTool(config, args) {
4532
5133
  */
4533
5134
  async function selectTool(config, args) {
4534
5135
  const page = await getPage(config);
5136
+ await assertExpectedUrl(page, args.expectUrl, "select");
5137
+ const extWarning = drainExternalNavWarning(page);
4535
5138
  const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
4536
5139
  "combobox",
4537
5140
  "select",
@@ -4554,6 +5157,7 @@ async function selectTool(config, args) {
4554
5157
  clear: args.clear === true
4555
5158
  })).selected,
4556
5159
  selector: label,
5160
+ ...extWarning !== void 0 ? { warning: extWarning } : {},
4557
5161
  ...ambiguityFields(targetRes),
4558
5162
  ...await pageIdentity(page)
4559
5163
  };
@@ -4561,8 +5165,8 @@ async function selectTool(config, args) {
4561
5165
  async function evalTool(config, args) {
4562
5166
  const page = await getPage(config);
4563
5167
  const { script, unwrapped } = unwrapDoubleEncodedScript(String(args.script ?? ""));
4564
- const wrapped = /[\n;]|^\s*return\b/.test(script) ? `(async () => {\n${script}\n})()` : `(${script})`;
4565
- const raw = await page.evaluate(wrapped);
5168
+ const { code, mode } = buildEvalScript(script);
5169
+ const raw = await page.evaluate(code);
4566
5170
  let result;
4567
5171
  try {
4568
5172
  result = JSON.parse(JSON.stringify(raw ?? null));
@@ -4571,6 +5175,7 @@ async function evalTool(config, args) {
4571
5175
  }
4572
5176
  return {
4573
5177
  result,
5178
+ mode,
4574
5179
  ...unwrapped ? { scriptUnwrapped: true } : {}
4575
5180
  };
4576
5181
  }
@@ -4771,6 +5376,41 @@ async function formFillTool(config, args) {
4771
5376
  ...submitted !== void 0 ? { submitted } : {}
4772
5377
  };
4773
5378
  }
5379
+ /**
5380
+ * browser_drag:在页面里把源元素拖到目标元素上。
5381
+ * 源/目标各自走统一定位管线(selector / ref / what+scope / mark 四选一前缀),
5382
+ * 支持 HTML5 原生拖放(dragTo)与鼠标拖动类控件(物理鼠标序列降级);
5383
+ * 跨 Frame 拖拽自动绕过 dragTo 的同 Frame 限制。
5384
+ */
5385
+ async function dragTool(config, args) {
5386
+ const pickSide = (side) => {
5387
+ const remapped = {};
5388
+ if (typeof args[side] === "string" && args[side] !== "") remapped.selector = args[side];
5389
+ if (typeof args[`${side}Ref`] === "string" && args[`${side}Ref`] !== "") remapped.ref = args[`${side}Ref`];
5390
+ if (typeof args[`${side}What`] === "string" && args[`${side}What`].trim() !== "") remapped.what = args[`${side}What`];
5391
+ if (typeof args[`${side}Mark`] === "number") remapped.mark = args[`${side}Mark`];
5392
+ if (typeof args[`${side}Scope`] === "string" && args[`${side}Scope`] !== "") remapped.scope = args[`${side}Scope`];
5393
+ 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 标徽)至少给一个`);
5394
+ return remapped;
5395
+ };
5396
+ 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);
5397
+ const srcArgs = pickSide("source");
5398
+ const tgtArgs = pickSide("target");
5399
+ const page = await getPage(config);
5400
+ const srcRes = await resolveTargetLocator(page, srcArgs);
5401
+ const tgtRes = await resolveTargetLocator(page, tgtArgs);
5402
+ const crossFrame = srcRes.container !== tgtRes.container;
5403
+ return {
5404
+ dragged: true,
5405
+ via: await dragAndDropLocators(page, srcRes.locator, tgtRes.locator, {
5406
+ timeoutMs: typeof args.timeoutMs === "number" ? args.timeoutMs : void 0,
5407
+ preferMouse: crossFrame
5408
+ }),
5409
+ source: describeSide(srcArgs),
5410
+ target: describeSide(tgtArgs),
5411
+ ...await pageIdentity(page)
5412
+ };
5413
+ }
4774
5414
  //#endregion
4775
5415
  //#region lib/types/browser/tools/testing.js
4776
5416
  async function waitTool(config, args) {
@@ -7624,7 +8264,11 @@ function getInteractionTools(config, timeout, stepImpls) {
7624
8264
  type: "boolean",
7625
8265
  description: "多命中即抛错(对齐 Playwright strict),要求定位唯一;默认 false 操作第 1 个并在返回中提示命中数。"
7626
8266
  },
7627
- timeoutMs: { type: "integer" }
8267
+ timeoutMs: { type: "integer" },
8268
+ expectUrl: {
8269
+ type: "string",
8270
+ description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
8271
+ }
7628
8272
  },
7629
8273
  additionalProperties: false
7630
8274
  },
@@ -7652,6 +8296,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7652
8296
  viaBridge: {
7653
8297
  type: "boolean",
7654
8298
  description: "what 未命中时经页面 __DSH_BRIDGE__(vite-plugin-dsh-bridge)兜底命中。"
8299
+ },
8300
+ warning: {
8301
+ type: "string",
8302
+ description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
7655
8303
  }
7656
8304
  },
7657
8305
  required: [
@@ -7666,7 +8314,7 @@ function getInteractionTools(config, timeout, stepImpls) {
7666
8314
  }),
7667
8315
  definePlainTool({
7668
8316
  name: "browser_type",
7669
- description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。",
8317
+ description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。定位与点击均失败时做焦点归属校验:焦点不在目标内(含子元素)则中止输入并附当前焦点线索,不会盲打到错误输入框;返回 typedVia 标明实际输入通道。expectUrl 传入时先校验当前页面 URL,不符直接报错(防页面被切换后打错位置)。",
7670
8318
  parameters: {
7671
8319
  type: "object",
7672
8320
  properties: {
@@ -7702,7 +8350,11 @@ function getInteractionTools(config, timeout, stepImpls) {
7702
8350
  description: "多命中即抛错(对齐 Playwright strict);多命中或 strict 时不再 force 降级点击,遮罩拦截会直接暴露。"
7703
8351
  },
7704
8352
  delayMs: { type: "integer" },
7705
- timeoutMs: { type: "integer" }
8353
+ timeoutMs: { type: "integer" },
8354
+ expectUrl: {
8355
+ type: "string",
8356
+ description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
8357
+ }
7706
8358
  },
7707
8359
  required: ["text"],
7708
8360
  additionalProperties: false
@@ -7712,6 +8364,20 @@ function getInteractionTools(config, timeout, stepImpls) {
7712
8364
  additionalProperties: false,
7713
8365
  properties: {
7714
8366
  typed: { type: "boolean" },
8367
+ typedVia: {
8368
+ type: "string",
8369
+ enum: [
8370
+ "fill",
8371
+ "clicked",
8372
+ "focus-kept",
8373
+ "editor"
8374
+ ],
8375
+ description: "实际输入通道:fill=clear 清空重填;clicked=点击获得焦点;focus-kept=点击超时但焦点已在目标内续打;editor=编辑器适配。"
8376
+ },
8377
+ focusedElement: {
8378
+ type: "string",
8379
+ description: "focus-kept 通道下当前焦点元素 outerHTML 片段(调试线索)。"
8380
+ },
7715
8381
  selector: { type: "string" },
7716
8382
  submit: { type: "boolean" },
7717
8383
  url: { type: "string" },
@@ -7727,6 +8393,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7727
8393
  hint: {
7728
8394
  type: "string",
7729
8395
  description: "matchedCount>1 时的消歧指引。"
8396
+ },
8397
+ warning: {
8398
+ type: "string",
8399
+ description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
7730
8400
  }
7731
8401
  },
7732
8402
  required: [
@@ -7841,7 +8511,11 @@ function getInteractionTools(config, timeout, stepImpls) {
7841
8511
  type: "boolean",
7842
8512
  description: "多命中即抛错(对齐 Playwright strict)。"
7843
8513
  },
7844
- timeoutMs: { type: "integer" }
8514
+ timeoutMs: { type: "integer" },
8515
+ expectUrl: {
8516
+ type: "string",
8517
+ description: "页面预期校验(opt-in):正则(非法时按子串);定位前当前 URL 不匹配直接报错,把「页面被切换/回退」从诡异失败变为一句话可诊断的错误。"
8518
+ }
7845
8519
  },
7846
8520
  required: ["option"],
7847
8521
  additionalProperties: false
@@ -7865,6 +8539,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7865
8539
  hint: {
7866
8540
  type: "string",
7867
8541
  description: "matchedCount>1 时的消歧指引。"
8542
+ },
8543
+ warning: {
8544
+ type: "string",
8545
+ description: "执行过程中检测到的环境异常提示(如非工具触发的页面导航)。"
7868
8546
  }
7869
8547
  },
7870
8548
  required: [
@@ -7878,9 +8556,95 @@ function getInteractionTools(config, timeout, stepImpls) {
7878
8556
  timeoutMs: timeout(),
7879
8557
  execute: (args) => selectTool(config, args)
7880
8558
  }),
8559
+ definePlainTool({
8560
+ name: "browser_drag",
8561
+ 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。源/目标不可见或零命中时抛错并附诊断。",
8562
+ parameters: {
8563
+ type: "object",
8564
+ properties: {
8565
+ source: {
8566
+ type: "string",
8567
+ description: "源元素 CSS 选择器;与 sourceRef/sourceWhat/sourceMark 互斥。"
8568
+ },
8569
+ sourceRef: {
8570
+ type: "string",
8571
+ description: "源元素 aria 快照 ref(如 'e3');与 source/sourceWhat/sourceMark 互斥。"
8572
+ },
8573
+ sourceWhat: {
8574
+ type: "string",
8575
+ description: "源元素意图描述(如 '任务卡片 A'):页内打分语义定位;与 source/sourceRef/sourceMark 互斥;配合 sourceScope。"
8576
+ },
8577
+ sourceMark: {
8578
+ type: "integer",
8579
+ description: "源元素 SoM 标徽编号(browser_screenshot { som: true });与 source/sourceRef/sourceWhat 互斥。"
8580
+ },
8581
+ sourceScope: {
8582
+ type: "string",
8583
+ description: "配合 sourceWhat 限定扫描范围的容器选择器。"
8584
+ },
8585
+ target: {
8586
+ type: "string",
8587
+ description: "目标元素 CSS 选择器;与 targetRef/targetWhat/targetMark 互斥。"
8588
+ },
8589
+ targetRef: {
8590
+ type: "string",
8591
+ description: "目标元素 aria 快照 ref;与 target/targetWhat/targetMark 互斥。"
8592
+ },
8593
+ targetWhat: {
8594
+ type: "string",
8595
+ description: "目标元素意图描述(如 '待办列'):页内打分语义定位;与 target/targetRef/targetMark 互斥;配合 targetScope。"
8596
+ },
8597
+ targetMark: {
8598
+ type: "integer",
8599
+ description: "目标元素 SoM 标徽编号;与 target/targetRef/targetWhat 互斥。"
8600
+ },
8601
+ targetScope: {
8602
+ type: "string",
8603
+ description: "配合 targetWhat 限定扫描范围的容器选择器。"
8604
+ },
8605
+ timeoutMs: { type: "integer" }
8606
+ },
8607
+ additionalProperties: false
8608
+ },
8609
+ outputSchema: {
8610
+ type: "object",
8611
+ additionalProperties: false,
8612
+ properties: {
8613
+ dragged: {
8614
+ type: "boolean",
8615
+ description: "拖拽序列已执行(true 不保证业务侧生效,drop 被拒绝也为 true)。"
8616
+ },
8617
+ via: {
8618
+ type: "string",
8619
+ description: "实际执行通道:'dragTo'(HTML5/组件库)或 'mouse'(物理鼠标,含跨 Frame 与降级)。"
8620
+ },
8621
+ source: {
8622
+ type: "string",
8623
+ description: "实际使用的源定位描述(what:/ref:/mark:/selector)。"
8624
+ },
8625
+ target: {
8626
+ type: "string",
8627
+ description: "实际使用的目标定位描述。"
8628
+ },
8629
+ url: { type: "string" },
8630
+ title: { type: "string" }
8631
+ },
8632
+ required: [
8633
+ "dragged",
8634
+ "via",
8635
+ "source",
8636
+ "target",
8637
+ "url",
8638
+ "title"
8639
+ ]
8640
+ },
8641
+ render: (_args, value) => `Dragged ${value.source} → ${value.target} (via ${value.via})`,
8642
+ timeoutMs: timeout(),
8643
+ execute: (args) => dragTool(config, args)
8644
+ }),
7881
8645
  definePlainTool({
7882
8646
  name: "browser_eval",
7883
- description: "在页面上下文执行一段 JavaScript。表达式直接求值(如 'document.title');含换行/分号的语句会被包进 async IIFE,可用 return 返回。结果必须是可 JSON 序列化的值。",
8647
+ description: "在页面上下文执行一段 JavaScript。表达式(含多行对象字面量/箭头函数 IIFE/顶层 await)直接返回值;语句脚本自动 return 最后一个顶层表达式的值,也可显式 return。结果必须是可 JSON 序列化的值,返回 mode 标明实际执行形态。",
7884
8648
  parameters: {
7885
8649
  type: "object",
7886
8650
  properties: { script: {
@@ -7895,6 +8659,15 @@ function getInteractionTools(config, timeout, stepImpls) {
7895
8659
  additionalProperties: false,
7896
8660
  properties: {
7897
8661
  result: {},
8662
+ mode: {
8663
+ type: "string",
8664
+ enum: [
8665
+ "expression",
8666
+ "statements-auto-return",
8667
+ "statements"
8668
+ ],
8669
+ description: "实际执行形态:expression=表达式直取值(支持顶层 await 与多行对象字面量);statements-auto-return=语句体自动 return 最后一个顶层表达式;statements=语句体(显式 return 或纯副作用,无返回值时 result 为 null)。"
8670
+ },
7898
8671
  scriptUnwrapped: {
7899
8672
  type: "boolean",
7900
8673
  description: "入参 script 疑似被 JSON 双重序列化(以引号包裹且含 \\n 字面量),已自动解码一层。"
@@ -9373,14 +10146,16 @@ function registerTools(ctx, config) {
9373
10146
  "browser_upload",
9374
10147
  "browser_form",
9375
10148
  "browser_form_fill",
9376
- "browser_eval"
10149
+ "browser_eval",
10150
+ "browser_drag"
9377
10151
  ]);
9378
10152
  /** 操作预告:这些工具执行前把目标元素在实时画面上高亮(异步不阻塞)。 */
9379
10153
  const PREVIEW_TOOLS = /* @__PURE__ */ new Set([
9380
10154
  "browser_click",
9381
10155
  "browser_type",
9382
10156
  "browser_select",
9383
- "browser_press"
10157
+ "browser_press",
10158
+ "browser_drag"
9384
10159
  ]);
9385
10160
  /**
9386
10161
  * auto 审批闸(confirmMode:"auto" 硬约束):live 调用在 register 包装层拦,
@@ -9435,8 +10210,8 @@ function registerTools(ctx, config) {
9435
10210
  sessionTracer.startStep(currentProfile, def.name, args, activePage());
9436
10211
  if (PREVIEW_TOOLS.has(def.name)) {
9437
10212
  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());
10213
+ 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 ?? "" : "";
10214
+ if (selector !== "") previewHighlight(page, selector, `${def.name.replace(/^browser_/, "")} ${args?.ref ? `[${args.ref}] ` : ""}${args?.key ?? args?.value ?? args?.text ?? args?.source ?? ""}`.trim());
9440
10215
  }
9441
10216
  try {
9442
10217
  const result = await original(args, exec);
@@ -9481,6 +10256,7 @@ function registerTools(ctx, config) {
9481
10256
  browser_type: (a) => typeTool(config, a),
9482
10257
  browser_press: (a) => pressTool(config, a),
9483
10258
  browser_select: (a) => selectTool(config, a),
10259
+ browser_drag: (a) => dragTool(config, a),
9484
10260
  browser_eval: (a) => evalTool(config, a),
9485
10261
  browser_screenshot: (a) => screenshotTool(config, a),
9486
10262
  browser_wait: (a) => waitTool(config, a),