dsh-browser-view 0.7.0 → 0.7.4

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/index.js CHANGED
@@ -572,12 +572,12 @@ function __walkerCore() {
572
572
  }
573
573
  if (el.id) {
574
574
  const t = document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.textContent?.trim();
575
- if (t) return t.slice(0, 60);
575
+ if (t) return t.replace(/^[*::\s]+|[*::\s]+$/g, "").slice(0, 60);
576
576
  }
577
577
  const wrapLabel = el.closest("label");
578
578
  if (wrapLabel) {
579
579
  const t = wrapLabel.textContent?.trim();
580
- if (t) return t.slice(0, 60);
580
+ if (t) return t.replace(/^[*::\s]+|[*::\s]+$/g, "").slice(0, 60);
581
581
  }
582
582
  if ([
583
583
  "checkbox",
@@ -587,17 +587,45 @@ function __walkerCore() {
587
587
  const container = el.closest("label, [class*='wrapper'], [class*='item'], li");
588
588
  if (container) {
589
589
  const t = container.textContent?.trim();
590
- if (t) return t.replace(/\s+/g, " ").slice(0, 60);
590
+ if (t) return t.replace(/\s+/g, " ").replace(/^[*::\s]+|[*::\s]+$/g, "").slice(0, 60);
591
591
  }
592
592
  }
593
593
  if ([
594
594
  "textbox",
595
595
  "searchbox",
596
596
  "combobox",
597
- "spinbutton"
597
+ "spinbutton",
598
+ "slider",
599
+ "listbox"
598
600
  ].includes(role)) {
601
+ const formItem = el.closest("[class*='form-item'], [class*='form__item'], [class*='form-group'], [role='group']");
602
+ if (formItem) {
603
+ const lab = formItem.querySelector("label, [class*='form-item-label'], [class*='form-item__label'], [class*='label']");
604
+ if (lab) {
605
+ const t = (lab.getAttribute("title") || lab.textContent || "").trim().replace(/^[*::\s]+|[*::\s]+$/g, "");
606
+ if (t) return t.slice(0, 60);
607
+ }
608
+ }
609
+ const selectHost = el.closest("[class*='select'], [class*='cascader'], [class*='picker']");
610
+ if (selectHost) {
611
+ const ph = selectHost.querySelector("[class*='placeholder'], [class*='selection-placeholder']")?.textContent?.trim();
612
+ if (ph) return ph.slice(0, 60);
613
+ const selVal = selectHost.querySelector("[class*='selection-item'], [class*='selected-value']")?.textContent?.trim();
614
+ if (selVal) return selVal.slice(0, 60);
615
+ }
599
616
  const ph = el.getAttribute("placeholder");
600
617
  if (ph && ph.trim()) return ph.trim().slice(0, 60);
618
+ const host = selectHost || el;
619
+ const prevEl = host.previousElementSibling;
620
+ if (prevEl) {
621
+ const prevText = (prevEl.textContent || "").trim().replace(/^[*::\s]+|[*::\s]+$/g, "");
622
+ if (prevText && prevText.length <= 30) return prevText.slice(0, 60);
623
+ }
624
+ const td = host.closest("td");
625
+ if (td && td.previousElementSibling) {
626
+ const tdText = (td.previousElementSibling.textContent || "").trim().replace(/^[*::\s]+|[*::\s]+$/g, "");
627
+ if (tdText && tdText.length <= 30) return tdText.slice(0, 60);
628
+ }
601
629
  }
602
630
  const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
603
631
  if (text) return text.slice(0, 60);
@@ -683,6 +711,25 @@ function __walkerCore() {
683
711
  visible: isVisible(el)
684
712
  };
685
713
  }
714
+ /** 疑似可点击 generic 元素(自定义组件的 span+click 等):无 ARIA 角色或显式
715
+ * presentational(presentation/none)但具备 cursor:pointer / onclick 属性 /
716
+ * tabindex≥0 特征且有非空文本(≤60 字符)。仅供扫描语义消费(what 语义扫描与
717
+ * SoM 标徽共用,防判定分叉);aria 快照不纳入(避免噪音)——这是记录在案的
718
+ * 设计分叉,见产品文档 03。 */
719
+ function isProbablyClickable(el) {
720
+ const role = roleOf(el);
721
+ if (role !== "" && role !== "presentation" && role !== "none") return false;
722
+ const text = (el.innerText || el.textContent || "").trim();
723
+ if (text === "" || text.length > 60) return false;
724
+ if (el.hasAttribute("onclick")) return true;
725
+ const ti = el.tabIndex;
726
+ if (typeof ti === "number" && ti >= 0) return true;
727
+ try {
728
+ return getComputedStyle(el).cursor === "pointer";
729
+ } catch {
730
+ return false;
731
+ }
732
+ }
686
733
  return {
687
734
  INTERACTIVE,
688
735
  STRUCTURAL,
@@ -694,7 +741,8 @@ function __walkerCore() {
694
741
  isElementInViewport,
695
742
  describeElement,
696
743
  composedParent,
697
- deepChildren
744
+ deepChildren,
745
+ isProbablyClickable
698
746
  };
699
747
  }
700
748
  /** walker 核心的序列化源码(模块加载期取一次;tsdown/vitest 均原样保留)。 */
@@ -1356,8 +1404,38 @@ async function executeUniversalSelect(ctx) {
1356
1404
  const options = await withTimeout(target.locator("option").allTextContents(), probe, "读取原生下拉选项").catch(() => []);
1357
1405
  throw new Error(`原生下拉无选项 "${want}"; 可用选项: ${options.map((t) => t.trim()).join(" / ")}`);
1358
1406
  }
1359
- const hostLocator = target.locator("xpath=ancestor-or-self::*[@role='combobox' or contains(@class, 'select') or contains(@class, 'selector')][last()]");
1360
- const actualTarget = await hostLocator.count().catch(() => 0) > 0 ? hostLocator.first() : target;
1407
+ let resolvedTarget = target;
1408
+ if (await withTimeout(target.evaluate((el) => {
1409
+ if (el.tagName.toLowerCase() === "label") return true;
1410
+ const cls = typeof el.className === "string" ? el.className : "";
1411
+ if (/(?:form-item|form__item|form-group)/i.test(cls)) return true;
1412
+ if (el.closest("label, [class*='form-item'], [class*='form__item'], [class*='form-group']")) return !el.matches("[role='combobox'], select, [class*='select-selector']");
1413
+ if (!el.matches("[role='combobox'], select, [class*='select'], [class*='selector']")) return true;
1414
+ return false;
1415
+ }), 1500, "探测 label 或非标文本容器").catch(() => false)) {
1416
+ const descendantSelect = target.locator("xpath=.//*[@role='combobox' or self::select or contains(@class, 'select-selector') or contains(@class, 'select')][not(self::label)]").first();
1417
+ if (await descendantSelect.count().catch(() => 0) > 0) resolvedTarget = descendantSelect;
1418
+ else {
1419
+ const forId = await target.getAttribute("for").catch(() => null);
1420
+ if (forId) {
1421
+ const forTarget = page.locator(`[id="${forId.replace(/"/g, "\\\"")}"]`).first();
1422
+ if (await forTarget.count().catch(() => 0) > 0) resolvedTarget = forTarget;
1423
+ } else {
1424
+ const nextSiblingSelect = target.locator("xpath=following-sibling::*[self::select or @role='combobox' or contains(@class, 'select') or contains(@class, 'selector')][1] | following-sibling::*[1]//*[@role='combobox' or self::select or contains(@class, 'select')]").first();
1425
+ if (await nextSiblingSelect.count().catch(() => 0) > 0) resolvedTarget = nextSiblingSelect;
1426
+ else {
1427
+ const tdSiblingSelect = target.locator("xpath=ancestor-or-self::td[1]/following-sibling::td[1]//*[@role='combobox' or self::select or contains(@class, 'select')]").first();
1428
+ if (await tdSiblingSelect.count().catch(() => 0) > 0) resolvedTarget = tdSiblingSelect;
1429
+ else {
1430
+ const siblingSelect = target.locator("xpath=ancestor-or-self::*[contains(@class, 'form-item') or contains(@class, 'form__item') or contains(@class, 'form-group')][1]//*[@role='combobox' or self::select or contains(@class, 'select-selector') or contains(@class, 'select')][not(self::label)] | ..//*[@role='combobox' or self::select or contains(@class, 'select-selector') or contains(@class, 'select')][not(self::label)]").first();
1431
+ if (await siblingSelect.count().catch(() => 0) > 0) resolvedTarget = siblingSelect;
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+ }
1437
+ const hostLocator = resolvedTarget.locator("xpath=ancestor-or-self::*[@role='combobox' or contains(@class, 'select') or contains(@class, 'selector')][last()]");
1438
+ const actualTarget = await hostLocator.count().catch(() => 0) > 0 ? hostLocator.first() : resolvedTarget;
1361
1439
  if (clear) try {
1362
1440
  const clearBtn = actualTarget.locator("[class*='clear'], [aria-label*='clear']").first();
1363
1441
  if (await clearBtn.count().catch(() => 0) > 0 && await clearBtn.isVisible().catch(() => false)) await clearBtn.click({ timeout: 1e3 }).catch(() => {});
@@ -1410,13 +1488,17 @@ async function executeUniversalSelect(ctx) {
1410
1488
  const optionSelector = "[role='option']:not([style*='height: 0px']):not([class*='empty']), [class*='item-option']:not([class*='empty']), [class*='select-dropdown'] li:not([class*='empty'])";
1411
1489
  const dropdownSelector = "[role='listbox']:not([style*='display: none']), [class*='select-dropdown']:not([style*='display: none']):not([class*='hidden']), [class*='popper']:not([style*='display: none'])";
1412
1490
  let options;
1491
+ let targetDropdown = null;
1413
1492
  if (controlsId) {
1414
1493
  const parentDropdown = page.locator(`xpath=//*[@id='${controlsId}']/ancestor-or-self::*[contains(@class, 'dropdown') or contains(@class, 'popper') or @role='listbox'][1] | //*[contains(@class, 'dropdown') or @role='listbox'][.//*[@id='${controlsId}']]`);
1494
+ targetDropdown = parentDropdown.first();
1415
1495
  const visual = parentDropdown.locator(optionSelector).locator("visible=true");
1416
1496
  options = await visual.count().catch(() => 0) > 0 ? visual : parentDropdown.locator(optionSelector);
1417
1497
  } else {
1418
- const activeDropdowns = page.locator(dropdownSelector);
1419
- const dropdown = await activeDropdowns.count().catch(() => 0) > 1 ? activeDropdowns.last() : activeDropdowns.first();
1498
+ const activeVisualDropdowns = page.locator(dropdownSelector).locator("visible=true");
1499
+ const visualCount = await activeVisualDropdowns.count().catch(() => 0);
1500
+ const dropdown = visualCount > 1 ? activeVisualDropdowns.last() : visualCount === 1 ? activeVisualDropdowns.first() : page.locator(dropdownSelector).last();
1501
+ targetDropdown = dropdown;
1420
1502
  const visual = dropdown.locator(optionSelector).locator("visible=true");
1421
1503
  options = await visual.count().catch(() => 0) > 0 ? visual : dropdown.locator(optionSelector);
1422
1504
  }
@@ -1432,7 +1514,7 @@ async function executeUniversalSelect(ctx) {
1432
1514
  };
1433
1515
  })).catch(() => [])).map((e) => e.text || e.title || e.aria).filter(Boolean);
1434
1516
  if (visibleTexts.length === 0) {
1435
- const isEmpty = await page.locator("[class*='empty'], [class*='no-data'], [role='status'], [class*='item-empty']").first().isVisible().catch(() => false);
1517
+ const isEmpty = targetDropdown !== null ? await targetDropdown.locator("[class*='empty'], [class*='no-data'], [role='status'], [class*='item-empty']").first().isVisible().catch(() => false) : false;
1436
1518
  await actualTarget.click({ timeout: 1e3 }).catch(() => {});
1437
1519
  if (isEmpty) throw new Error(`下拉浮层当前无选项(数据为空态/无可选数据): ${label}`);
1438
1520
  throw new Error(`点击 ${label} 后未出现下拉选项(选项列表为空态)`);
@@ -1650,7 +1732,7 @@ async function fillFormFieldHelper(page, field, scope) {
1650
1732
  * 多 token(空格/中英混排拆分)按 AND 命中 —— 单 token 行为与旧子串匹配等价。
1651
1733
  */
1652
1734
  const scanInteractive = injectWalker((payload, w) => {
1653
- const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, deepChildren, composedParent } = w;
1735
+ const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, deepChildren, composedParent, isProbablyClickable } = w;
1654
1736
  function resolveScopeRoot(scopeStr) {
1655
1737
  if (!scopeStr) return document.body;
1656
1738
  const wantsVisible = /:visible\b/.test(scopeStr);
@@ -1741,6 +1823,7 @@ const scanInteractive = injectWalker((payload, w) => {
1741
1823
  }
1742
1824
  const hit = payload.exact ? legacyHit : legacyHit || tokens.length > 0 && tokenHit;
1743
1825
  if (legacyHit && name !== "" && name.toLowerCase() === q) score += 50;
1826
+ if (payload.preferredRoles && payload.preferredRoles.includes(role)) score += 40;
1744
1827
  scanned += 1;
1745
1828
  if (name !== "" && candidates.length < 50) candidates.push({
1746
1829
  role,
@@ -1759,6 +1842,39 @@ const scanInteractive = injectWalker((payload, w) => {
1759
1842
  });
1760
1843
  continue;
1761
1844
  }
1845
+ if (isVisible(el) && isProbablyClickable(el)) {
1846
+ const text = (el.innerText || el.textContent || "").trim().slice(0, 60);
1847
+ const n = text.toLowerCase();
1848
+ const nStr = n.replace(/\s+/g, "");
1849
+ const legacyHit = payload.exact ? n === q || nStr === qStripped : n.includes(q) || qStripped !== "" && nStr.includes(qStripped);
1850
+ let tokenScore = 0;
1851
+ let tokenHit = true;
1852
+ for (const t of tokens) {
1853
+ const tStr = t.replace(/\s+/g, "");
1854
+ if (n.includes(t) || tStr !== "" && nStr.includes(tStr)) tokenScore += 3;
1855
+ else {
1856
+ tokenHit = false;
1857
+ break;
1858
+ }
1859
+ }
1860
+ if (legacyHit && n === q) tokenScore += 50;
1861
+ const hit = payload.exact ? legacyHit : legacyHit || tokens.length > 0 && tokenHit;
1862
+ if (candidates.length < 50) candidates.push({
1863
+ role: "clickable",
1864
+ name: text,
1865
+ selector: cssPath(el)
1866
+ });
1867
+ if (hit && hits.length < 100) hits.push({
1868
+ role: "clickable",
1869
+ name: text,
1870
+ selector: cssPath(el),
1871
+ text,
1872
+ visible: true,
1873
+ inDialog: dialogSel !== "" ? Boolean(el.closest(dialogSel)) : false,
1874
+ score: (legacyHit ? 100 : 0) + tokenScore - 200,
1875
+ ancestors: ancestorsOf(el)
1876
+ });
1877
+ }
1762
1878
  for (const c of deepChildren(el)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) stack.push(c);
1763
1879
  }
1764
1880
  hits.sort((a, b) => Number(b.inDialog) - Number(a.inDialog) || b.score - a.score);
@@ -1804,7 +1920,8 @@ async function resolveSemanticTarget(page, what, opts = {}) {
1804
1920
  exact: opts.exact === true,
1805
1921
  scope: typeof opts.scope === "string" && opts.scope !== "" ? opts.scope : "",
1806
1922
  limit: Math.min(Math.max(opts.limit ?? 5, 1), 20),
1807
- dialogSelector: ACTIVE_DIALOG_SELECTORS
1923
+ dialogSelector: ACTIVE_DIALOG_SELECTORS,
1924
+ preferredRoles: opts.preferredRoles
1808
1925
  });
1809
1926
  } catch {
1810
1927
  return {
@@ -1838,7 +1955,7 @@ async function resolveSemanticTarget(page, what, opts = {}) {
1838
1955
  * 同一 walker 语义。上限 300 条,超出截断(超大页面重锚定按命中部分处理)。
1839
1956
  */
1840
1957
  const scanAllInteractive = injectWalker((payload, w) => {
1841
- const { INTERACTIVE, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren } = w;
1958
+ const { INTERACTIVE, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren, isProbablyClickable } = w;
1842
1959
  const out = [];
1843
1960
  let truncated = false;
1844
1961
  const snapScroll = {
@@ -1882,6 +1999,24 @@ const scanAllInteractive = injectWalker((payload, w) => {
1882
1999
  });
1883
2000
  continue;
1884
2001
  }
2002
+ if (out.length < 300 && isVisible(el) && isProbablyClickable(el)) {
2003
+ const text = (el.innerText || el.textContent || "").trim();
2004
+ const rect = el.getBoundingClientRect();
2005
+ out.push({
2006
+ role: "clickable",
2007
+ name: text.slice(0, 60),
2008
+ selector: cssPath(el),
2009
+ ...isElementInViewport(el, 0) ? {
2010
+ rect: {
2011
+ x: Math.round(rect.left),
2012
+ y: Math.round(rect.top),
2013
+ w: Math.round(rect.width),
2014
+ h: Math.round(rect.height)
2015
+ },
2016
+ scroll: snapScroll
2017
+ } : {}
2018
+ });
2019
+ }
1885
2020
  for (const c of deepChildren(el)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) stack.push(c);
1886
2021
  }
1887
2022
  return {
@@ -1963,7 +2098,19 @@ function nameSimilar(a, b) {
1963
2098
  * 自动识别 selector 或 ref;若 ref 标记位于子 Frame,自动绑定到对应子 Frame 上下文。
1964
2099
  * 具备路径防漂移校验与 getByRole 自愈降级能力。
1965
2100
  */
1966
- async function resolveTargetLocator(page, args) {
2101
+ async function resolveTargetLocator(page, args, opts = {}) {
2102
+ {
2103
+ const present = [];
2104
+ if (args.ref !== void 0 && args.ref !== "") present.push("ref");
2105
+ if (args.mark !== void 0) present.push("mark");
2106
+ if (typeof args.what === "string" && args.what.trim() !== "") present.push("what");
2107
+ if (args.selector !== void 0 && args.selector !== "") present.push("selector");
2108
+ if ((args.rowText !== void 0 || args.row !== void 0) && present.some((k) => k !== "selector")) {
2109
+ const offending = present.filter((k) => k !== "selector");
2110
+ throw new Error(`定位参数冲突: row/rowText 与 ${offending.join("、")} 不能同时使用(行级定位仅可组合 selector 限定行内目标);在行内按意图找按钮请用 row + action/actionText`);
2111
+ }
2112
+ if (present.length >= 2) throw new Error(`定位参数冲突: 同时传了 ${present.join(" 与 ")} —— ref/mark/what/selector 四选一;若要在容器内语义定位,请用 what + scope(容器选择器)`);
2113
+ }
1967
2114
  if (args.ref !== void 0 && args.ref !== "") {
1968
2115
  const ref = String(args.ref);
1969
2116
  const entry = pageRef(page, ref);
@@ -2141,7 +2288,10 @@ async function resolveTargetLocator(page, args) {
2141
2288
  }
2142
2289
  const what = args.what;
2143
2290
  if (typeof what === "string" && what.trim() !== "" && args.selector === void 0 && args.ref === void 0 && args.mark === void 0 && rowPattern === void 0) {
2144
- const res = await resolveSemanticTarget(page, what, { scope: typeof args.scope === "string" && args.scope !== "" ? args.scope : void 0 });
2291
+ const res = await resolveSemanticTarget(page, what, {
2292
+ scope: typeof args.scope === "string" && args.scope !== "" ? args.scope : void 0,
2293
+ preferredRoles: opts.preferredRoles
2294
+ });
2145
2295
  if (!("candidates" in res)) return res;
2146
2296
  for (const role of [
2147
2297
  "button",
@@ -2159,6 +2309,29 @@ async function resolveTargetLocator(page, args) {
2159
2309
  visibleCount: 1
2160
2310
  };
2161
2311
  }
2312
+ {
2313
+ const bridgeHit = await withTimeout(page.evaluate(async (q) => {
2314
+ const b = window.__DSH_BRIDGE__;
2315
+ if (b === void 0 || typeof b.resolveActionTarget !== "function") return null;
2316
+ const targets = await b.resolveActionTarget({ query: q });
2317
+ if (!Array.isArray(targets) || targets.length === 0) return null;
2318
+ const t = targets[0];
2319
+ if (t === null || typeof t !== "object" || typeof t.selector !== "string" || t.selector === "") return null;
2320
+ return { selector: t.selector };
2321
+ }, what), 2500, "bridge 兜底").catch(() => null);
2322
+ const bridgeSelector = bridgeHit !== null && typeof bridgeHit === "object" && typeof bridgeHit.selector === "string" && bridgeHit.selector !== "" ? bridgeHit.selector : null;
2323
+ if (bridgeSelector !== null) {
2324
+ const loc = page.locator(bridgeSelector).first();
2325
+ if (await withTimeout(loc.count(), 1500, "bridge 兜底定位").catch(() => 0) > 0) return {
2326
+ locator: loc,
2327
+ container: page,
2328
+ isIFrame: false,
2329
+ matchedCount: 1,
2330
+ visibleCount: 1,
2331
+ viaBridge: true
2332
+ };
2333
+ }
2334
+ }
2162
2335
  const err = new Error(semanticMissMessage(res));
2163
2336
  err.semanticMiss = res;
2164
2337
  err.locationFailure = {
@@ -2756,7 +2929,7 @@ async function launchProfile(name, config) {
2756
2929
  ...options,
2757
2930
  headless: resolved.headless ?? false
2758
2931
  });
2759
- for (const existing of context.pages()) existing.close().catch(() => {});
2932
+ await Promise.race([Promise.all(context.pages().map((p) => p.close().catch(() => {}))), new Promise((r) => setTimeout(r, 3e3))]);
2760
2933
  state.browser = null;
2761
2934
  state.context = context;
2762
2935
  state.persistent = true;
@@ -2781,11 +2954,30 @@ const namedContexts = /* @__PURE__ */ new Map();
2781
2954
  function namedContextsOf() {
2782
2955
  return namedContexts;
2783
2956
  }
2784
- /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。 */
2957
+ /** 同步摘除已关闭页面(手动关标签/渲染崩溃的僵尸句柄)。
2958
+ * activeId 指向被摘页时置 null 而非自动迁移——静默换页对 click/type 是错页执行,
2959
+ * 由 getPage 报错引导显式恢复(switch / browser_open 自愈)。 */
2960
+ function pruneClosedPages(state) {
2961
+ for (const [id, page] of state.pages) if (page.isClosed()) {
2962
+ state.pages.delete(id);
2963
+ state.tabMeta?.delete(id);
2964
+ if (state.activeId === id) state.activeId = null;
2965
+ }
2966
+ }
2967
+ /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。
2968
+ * 唯一注册路径:同时挂 close 监听幂等摘除外部关闭的页(否则僵尸句柄会永久占用
2969
+ * activeId,所有工具连报 "Target page...closed" 而 tabs list 仍显示存活)。 */
2785
2970
  function registerTabPage(state, page) {
2786
2971
  const id = state.counter++;
2787
2972
  state.pages.set(id, page);
2788
2973
  state.activeId = id;
2974
+ (state.tabMeta ??= /* @__PURE__ */ new Map()).set(id, { createdAt: Date.now() });
2975
+ page.on("close", () => {
2976
+ if (!state.pages.has(id)) return;
2977
+ state.pages.delete(id);
2978
+ state.tabMeta?.delete(id);
2979
+ if (state.activeId === id) state.activeId = null;
2980
+ });
2789
2981
  return id;
2790
2982
  }
2791
2983
  /** 新建一个页面(当前 profile 会话未启动时先启动)。 */
@@ -2811,34 +3003,42 @@ async function newPage(config) {
2811
3003
  async function getPage(config) {
2812
3004
  const state = stateOf();
2813
3005
  if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
3006
+ pruneClosedPages(state);
2814
3007
  if (state.pages.size === 0) {
2815
3008
  const page = await newPage(config);
2816
- const id = state.counter++;
2817
- state.pages.set(id, page);
2818
- state.activeId = id;
3009
+ registerTabPage(state, page);
3010
+ return page;
2819
3011
  }
2820
3012
  const page = state.pages.get(state.activeId ?? -1);
2821
- if (page === void 0) throw new Error("dsh-browser: no active page");
3013
+ if (page === void 0) {
3014
+ const err = /* @__PURE__ */ new Error("dsh-browser: 活动标签已关闭或崩溃;请 browser_tabs { action: \"list\" } 查看存活标签并 switch,或 browser_open 打开目标 URL(将自动新建标签)");
3015
+ err.code = "activeTabClosed";
3016
+ throw err;
3017
+ }
2822
3018
  return page;
2823
3019
  }
2824
3020
  /** 活动页面(可能为 null,不触发启动)。 */
2825
3021
  function activePage() {
2826
3022
  const state = stateOf();
3023
+ pruneClosedPages(state);
2827
3024
  return state.pages.get(state.activeId ?? -1) ?? null;
2828
3025
  }
2829
- /** 投影当前 profile 的标签列表。 */
3026
+ /** 投影当前 profile 的标签列表(先摘除僵尸页,附注册时间)。 */
2830
3027
  async function tabList() {
2831
3028
  const state = stateOf();
3029
+ pruneClosedPages(state);
2832
3030
  const tabs = [];
2833
3031
  for (const [id, page] of state.pages) {
2834
3032
  let title = "";
2835
3033
  try {
2836
3034
  title = await page.title();
2837
3035
  } catch {}
3036
+ const createdAt = state.tabMeta?.get(id)?.createdAt;
2838
3037
  tabs.push({
2839
3038
  id,
2840
3039
  url: page.url(),
2841
- title
3040
+ title,
3041
+ ...createdAt !== void 0 ? { createdAt } : {}
2842
3042
  });
2843
3043
  }
2844
3044
  return tabs;
@@ -2851,6 +3051,7 @@ async function closeBrowser() {
2851
3051
  state.browser = null;
2852
3052
  state.context = null;
2853
3053
  state.pages = /* @__PURE__ */ new Map();
3054
+ state.tabMeta?.clear();
2854
3055
  state.activeId = null;
2855
3056
  state.counter = 0;
2856
3057
  try {
@@ -2893,15 +3094,38 @@ async function openTool(configOrArgs, maybeArgs) {
2893
3094
  const isSingleArg = maybeArgs === void 0;
2894
3095
  const config = isSingleArg && typeof configOrArgs?.url === "string" ? {} : configOrArgs;
2895
3096
  const args = isSingleArg ? configOrArgs : maybeArgs;
2896
- const page = await getPage(config ?? {});
2897
- await page.goto(String(args.url), {
2898
- waitUntil: args.waitUntil ?? "domcontentloaded",
2899
- timeout: args.timeoutMs ?? config?.timeoutMs
3097
+ const state = stateOf();
3098
+ let recovered;
3099
+ const freshPage = async () => {
3100
+ const p = await newPage(config ?? {});
3101
+ registerTabPage(state, p);
3102
+ recovered = "new-tab";
3103
+ return p;
3104
+ };
3105
+ let page = await getPage(config ?? {}).catch(async (e) => {
3106
+ if (e?.code === "activeTabClosed") return freshPage();
3107
+ throw e;
2900
3108
  });
3109
+ if (page.isClosed()) page = await freshPage();
3110
+ try {
3111
+ await page.goto(String(args.url), {
3112
+ waitUntil: args.waitUntil ?? "domcontentloaded",
3113
+ timeout: args.timeoutMs ?? config?.timeoutMs
3114
+ });
3115
+ } catch (e) {
3116
+ if (recovered === void 0 && /has been closed/.test(String(e?.message ?? e))) {
3117
+ page = await freshPage();
3118
+ await page.goto(String(args.url), {
3119
+ waitUntil: args.waitUntil ?? "domcontentloaded",
3120
+ timeout: args.timeoutMs ?? config?.timeoutMs
3121
+ });
3122
+ } else throw e;
3123
+ }
2901
3124
  return {
2902
3125
  url: page.url(),
2903
3126
  title: await page.title(),
2904
- status: "ok"
3127
+ status: "ok",
3128
+ ...recovered !== void 0 ? { recovered } : {}
2905
3129
  };
2906
3130
  }
2907
3131
  async function backTool(config) {
@@ -3057,9 +3281,7 @@ async function tabsTool(config, args) {
3057
3281
  };
3058
3282
  case "new": {
3059
3283
  const page = await newPage(config);
3060
- const id = state.counter++;
3061
- state.pages.set(id, page);
3062
- state.activeId = id;
3284
+ const id = registerTabPage(state, page);
3063
3285
  if (args.url) await page.goto(String(args.url), {
3064
3286
  waitUntil: args.waitUntil ?? "domcontentloaded",
3065
3287
  timeout: args.timeoutMs ?? config.timeoutMs
@@ -3089,12 +3311,10 @@ async function tabsTool(config, args) {
3089
3311
  const id = resolveTabId(args);
3090
3312
  const page = state.pages.get(id);
3091
3313
  if (page === void 0) throw new Error(`no tab with id ${id}`);
3092
- await page.close().catch(() => {});
3093
3314
  state.pages.delete(id);
3094
- if (state.activeId === id) {
3095
- const next = state.pages.keys().next().value;
3096
- state.activeId = next === void 0 ? null : next;
3097
- }
3315
+ state.tabMeta?.delete(id);
3316
+ if (state.activeId === id) state.activeId = null;
3317
+ await page.close().catch(() => {});
3098
3318
  return {
3099
3319
  action,
3100
3320
  activeId: state.activeId ?? -1,
@@ -3609,7 +3829,7 @@ const removeSomOverlay = () => {
3609
3829
  document.getElementById("__dsh_som_overlay__")?.remove();
3610
3830
  };
3611
3831
  const injectSomOverlay = injectWalker((payload, w) => {
3612
- const { INTERACTIVE, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren } = w;
3832
+ const { INTERACTIVE, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren, isProbablyClickable } = w;
3613
3833
  const marks = [];
3614
3834
  const snapScroll = {
3615
3835
  x: window.scrollX,
@@ -3625,6 +3845,7 @@ const injectSomOverlay = injectWalker((payload, w) => {
3625
3845
  candidates.push(el);
3626
3846
  return;
3627
3847
  }
3848
+ if (isVisible(el) && isElementInViewport(el, 0) && isProbablyClickable(el)) candidates.push(el);
3628
3849
  for (const c of deepChildren(el)) if (c.tagName !== "SCRIPT" && c.tagName !== "STYLE") collect(c);
3629
3850
  }
3630
3851
  if (document.body) collect(document.body);
@@ -3635,8 +3856,9 @@ const injectSomOverlay = injectWalker((payload, w) => {
3635
3856
  const rect = el.getBoundingClientRect();
3636
3857
  if (rect.width < 8 || rect.height < 8) continue;
3637
3858
  count += 1;
3638
- const role = roleOf(el);
3639
- const name = nameOf(el, role);
3859
+ const realRole = roleOf(el);
3860
+ const role = realRole === "" ? "clickable" : realRole;
3861
+ const name = realRole === "" ? (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60) : nameOf(el, realRole);
3640
3862
  const x = Math.max(0, rect.left);
3641
3863
  const y = Math.max(0, rect.top);
3642
3864
  const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
@@ -3953,6 +4175,22 @@ async function humanType(page, text, options = {}) {
3953
4175
  }
3954
4176
  //#endregion
3955
4177
  //#region lib/types/browser/tools/interaction.js
4178
+ /** 多命中可见化:解析层 matchedCount/visibleCount 透传到成功返回,>1 附操作指引。
4179
+ * 防同名字段静默打进被遮罩的背景元素(背景表单 + Modal 同名字段场景)。 */
4180
+ function ambiguityFields(res) {
4181
+ const matched = res.matchedCount;
4182
+ if (matched === void 0) return {};
4183
+ return {
4184
+ matchedCount: matched,
4185
+ ...res.visibleCount !== void 0 ? { visibleCount: res.visibleCount } : {},
4186
+ ...matched > 1 ? { hint: `命中 ${matched} 个元素(可见 ${res.visibleCount ?? 0}),已操作第 1 个;建议用 scope 限定或 browser_snapshot 取 ref 消歧` } : {}
4187
+ };
4188
+ }
4189
+ /** strict 模式(对齐 Playwright strict):多命中直接拒绝,要求定位唯一。 */
4190
+ function strictReject(args, res) {
4191
+ if (args.strict !== true || (res.matchedCount ?? 0) <= 1) return null;
4192
+ return /* @__PURE__ */ new Error(`strict 模式拒绝多命中: 命中 ${res.matchedCount} 个元素(可见 ${res.visibleCount ?? 0}),要求定位唯一;请用 scope 限定、更特异的 selector 或 browser_snapshot 取 ref`);
4193
+ }
3956
4194
  function isBotSensitiveUrl(url) {
3957
4195
  if (!url) return false;
3958
4196
  return /login|signin|auth|captcha|challenge|turnstile|security|waf/i.test(url);
@@ -3982,6 +4220,8 @@ async function clickTool(config, args, exec) {
3982
4220
  }
3983
4221
  let target = targetRes.locator;
3984
4222
  const label = String(args.ref ?? args.selector ?? args.mark ?? args.what ?? "");
4223
+ const strictErr = strictReject(args, targetRes);
4224
+ if (strictErr !== null) throw strictErr;
3985
4225
  target = await liftTargetIfHiddenInput(target);
3986
4226
  const wantEffect = args.effect === true;
3987
4227
  let urlBefore = "";
@@ -4074,17 +4314,28 @@ async function clickTool(config, args, exec) {
4074
4314
  clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
4075
4315
  effect,
4076
4316
  ...quiet ? { effectHint: "点击后未观察到任何 DOM/URL/网络变化,可能未生效(被遮挡/事件被吞/前置条件未满足);建议 browser_snapshot 核实或换目标" } : {},
4317
+ ...ambiguityFields(targetRes),
4318
+ ...targetRes.viaBridge === true ? { viaBridge: true } : {},
4077
4319
  ...await pageIdentity(page)
4078
4320
  };
4079
4321
  }
4080
4322
  return {
4081
4323
  clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
4324
+ ...ambiguityFields(targetRes),
4325
+ ...targetRes.viaBridge === true ? { viaBridge: true } : {},
4082
4326
  ...await pageIdentity(page)
4083
4327
  };
4084
4328
  }
4085
4329
  async function typeTool(config, args) {
4086
4330
  const page = await getPage(config);
4087
- const target = await targetLocator(page, args);
4331
+ const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
4332
+ "textbox",
4333
+ "searchbox",
4334
+ "spinbutton"
4335
+ ] });
4336
+ const target = targetRes.locator;
4337
+ const strictErr = strictReject(args, targetRes);
4338
+ if (strictErr !== null) throw strictErr;
4088
4339
  const text = String(args.text ?? "");
4089
4340
  const isHuman = args.humanize === true || args.humanize === void 0 && (config.humanize === true || config.humanize === "auto" && isBotSensitiveUrl(page.url()));
4090
4341
  if (args.editor === true) {
@@ -4093,6 +4344,7 @@ async function typeTool(config, args) {
4093
4344
  typed: true,
4094
4345
  editor: true,
4095
4346
  selector,
4347
+ ...ambiguityFields(targetRes),
4096
4348
  ...await pageIdentity(page)
4097
4349
  };
4098
4350
  }
@@ -4102,9 +4354,11 @@ async function typeTool(config, args) {
4102
4354
  await humanType(page, text, { speed: config.humanizeSpeed });
4103
4355
  } else await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
4104
4356
  } else {
4357
+ const ambiguous = args.strict === true || (targetRes.matchedCount ?? 0) > 1;
4105
4358
  try {
4106
4359
  await target.click({ timeout: Math.min(args.timeoutMs ?? config.timeoutMs, 5e3) });
4107
- } catch {
4360
+ } catch (e) {
4361
+ if (ambiguous) throw new Error(`type 定位目标存在歧义(命中 ${targetRes.matchedCount ?? "?"} 个元素)且首个不可交互,已中止;请用 scope 限定或 browser_snapshot 取 ref 消歧;原始错误: ${e instanceof Error ? e.message : String(e)}`);
4108
4362
  await target.click({
4109
4363
  timeout: 2e3,
4110
4364
  force: true
@@ -4118,6 +4372,7 @@ async function typeTool(config, args) {
4118
4372
  typed: true,
4119
4373
  selector: String(args.ref ?? args.selector),
4120
4374
  submit: args.submit === true,
4375
+ ...ambiguityFields(targetRes),
4121
4376
  ...await pageIdentity(page)
4122
4377
  };
4123
4378
  }
@@ -4144,8 +4399,18 @@ async function pressTool(config, args) {
4144
4399
  } else warning = "检测到页面存在活动弹窗 [modal: closeOnEsc],按全局 Escape 可能导致弹窗及其表单被关闭。若需关闭指定元素请传 selector/ref。";
4145
4400
  }
4146
4401
  }
4147
- if (args.selector !== void 0 || args.ref !== void 0 || typeof args.what === "string" && args.what.trim() !== "") await (await targetLocator(page, args)).press(key, { timeout: args.timeoutMs ?? config.timeoutMs });
4148
- else await page.keyboard.press(key);
4402
+ if (args.selector !== void 0 || args.ref !== void 0 || typeof args.what === "string" && args.what.trim() !== "") {
4403
+ const targetRes = await resolveTargetLocator(page, args);
4404
+ const strictErr = strictReject(args, targetRes);
4405
+ if (strictErr !== null) throw strictErr;
4406
+ await targetRes.locator.press(key, { timeout: args.timeoutMs ?? config.timeoutMs });
4407
+ return {
4408
+ pressed: key,
4409
+ ...warning ? { warning } : {},
4410
+ ...ambiguityFields(targetRes),
4411
+ ...await pageIdentity(page)
4412
+ };
4413
+ } else await page.keyboard.press(key);
4149
4414
  return {
4150
4415
  pressed: key,
4151
4416
  ...warning ? { warning } : {},
@@ -4158,8 +4423,15 @@ async function pressTool(config, args) {
4158
4423
  */
4159
4424
  async function selectTool(config, args) {
4160
4425
  const page = await getPage(config);
4161
- const target = await targetLocator(page, args);
4162
- const label = String(args.ref ?? args.selector);
4426
+ const targetRes = await resolveTargetLocator(page, args, { preferredRoles: [
4427
+ "combobox",
4428
+ "select",
4429
+ "listbox"
4430
+ ] });
4431
+ const target = targetRes.locator;
4432
+ const strictErr = strictReject(args, targetRes);
4433
+ if (strictErr !== null) throw strictErr;
4434
+ const label = String(args.ref ?? args.selector ?? args.what ?? "");
4163
4435
  const want = String(args.option ?? "");
4164
4436
  if (!want) throw new Error("select 需要 option(选项文本)");
4165
4437
  return {
@@ -4173,6 +4445,7 @@ async function selectTool(config, args) {
4173
4445
  clear: args.clear === true
4174
4446
  })).selected,
4175
4447
  selector: label,
4448
+ ...ambiguityFields(targetRes),
4176
4449
  ...await pageIdentity(page)
4177
4450
  };
4178
4451
  }
@@ -4482,7 +4755,53 @@ async function checkOnce(page, cond) {
4482
4755
  detail: `eval 执行失败: ${e instanceof Error ? e.message : String(e)}`
4483
4756
  };
4484
4757
  }
4485
- throw new Error("条件无效: 需要 selector/url/text/count/eval 之一");
4758
+ if (cond.bridge !== void 0 && typeof cond.bridge === "object") {
4759
+ const spec = cond.bridge;
4760
+ const method = String(spec.method ?? "");
4761
+ const trunc = (v) => {
4762
+ const s = JSON.stringify(v) ?? String(v);
4763
+ return s.length > 500 ? `${s.slice(0, 500)}…(截断)` : s;
4764
+ };
4765
+ if (method === "") return {
4766
+ ok: false,
4767
+ detail: "bridge 条件需要 method(__DSH_BRIDGE__ 方法名)"
4768
+ };
4769
+ try {
4770
+ const value = await page.evaluate(async (payload) => {
4771
+ const bridge = window.__DSH_BRIDGE__;
4772
+ if (bridge === void 0 || bridge === null) throw new Error("NO_BRIDGE:页面无 __DSH_BRIDGE__(未接入 vite-plugin-dsh-bridge?)");
4773
+ if (typeof bridge[payload.method] !== "function") throw new Error(`NO_BRIDGE:__DSH_BRIDGE__ 无方法 ${payload.method}`);
4774
+ return await bridge[payload.method](...payload.args);
4775
+ }, {
4776
+ method,
4777
+ args: Array.isArray(spec.args) ? spec.args : []
4778
+ });
4779
+ const actual = JSON.parse(JSON.stringify(value ?? null));
4780
+ if (spec.expectContains !== void 0) return (typeof actual === "string" ? actual : JSON.stringify(actual)).includes(String(spec.expectContains)) ? { ok: true } : {
4781
+ ok: false,
4782
+ detail: `[bridge] ${method}() 结果不含 "${spec.expectContains}"(实际: ${trunc(actual)})`
4783
+ };
4784
+ if (spec.expect !== void 0) return JSON.stringify(actual) === JSON.stringify(spec.expect) ? { ok: true } : {
4785
+ ok: false,
4786
+ detail: `[bridge] ${method}() = ${trunc(actual)} ≠ 期望 ${trunc(spec.expect)}`
4787
+ };
4788
+ return actual ? { ok: true } : {
4789
+ ok: false,
4790
+ detail: `[bridge] ${method}() 返回假值: ${trunc(actual)}`
4791
+ };
4792
+ } catch (e) {
4793
+ const msg = e instanceof Error ? e.message : String(e);
4794
+ if (msg.startsWith("NO_BRIDGE:")) return {
4795
+ ok: false,
4796
+ detail: `[bridge] ${msg.slice(10)}`
4797
+ };
4798
+ return {
4799
+ ok: false,
4800
+ detail: `[bridge] ${method}() 执行失败: ${msg}`
4801
+ };
4802
+ }
4803
+ }
4804
+ throw new Error("条件无效: 需要 selector/url/text/count/eval/bridge 之一");
4486
4805
  }
4487
4806
  /** 轮询等待条件成立(intervalMs 步进),返回最后一次检查结果。 */
4488
4807
  async function pollCondition(page, cond, timeoutMs, intervalMs = 500) {
@@ -4668,12 +4987,28 @@ async function visualAssert(config, visual, page, exec) {
4668
4987
  };
4669
4988
  }
4670
4989
  function matchNetwork(entry, f) {
4671
- if (f.urlSubstr && !entry.url.includes(String(f.urlSubstr))) return false;
4990
+ if (f.urlSubstr && !entry.url.toLowerCase().includes(String(f.urlSubstr).toLowerCase())) return false;
4672
4991
  if (f.method && entry.method.toUpperCase() !== String(f.method).toUpperCase()) return false;
4673
4992
  if (f.status !== void 0 && Number(f.status) !== entry.status) return false;
4674
4993
  if (f.failedOnly === true && !entry.failed && (entry.status === void 0 || entry.status < 400)) return false;
4675
4994
  return true;
4676
4995
  }
4996
+ /** urlSubstr 零命中时的相近 URL 候选(top5,去重,LCS 打分),与 what 未命中的
4997
+ * 候选体验对齐——把"子串写错"与"接口没调"区分开。 */
4998
+ function urlSuggestions(entries, substr) {
4999
+ const min = substr.trim().length <= 3 ? 1 : 2;
5000
+ const seen = /* @__PURE__ */ new Set();
5001
+ const scored = [];
5002
+ for (const e of entries) {
5003
+ if (seen.has(e.url)) continue;
5004
+ seen.add(e.url);
5005
+ scored.push({
5006
+ url: e.url,
5007
+ score: lcsScore(substr, e.url)
5008
+ });
5009
+ }
5010
+ return scored.filter((s) => s.score >= min).sort((a, b) => b.score - a.score).slice(0, 5).map((s) => s.url);
5011
+ }
4677
5012
  /** 网络记录查询:list / failed / wait / clear。兼容 (args) 与 (config, args) 两种调用签名。 */
4678
5013
  async function networkTool(configOrArgs, maybeArgs) {
4679
5014
  const args = (maybeArgs !== void 0 ? maybeArgs : configOrArgs) ?? {};
@@ -4748,8 +5083,20 @@ async function networkTool(configOrArgs, maybeArgs) {
4748
5083
  byStatus: b.byStatus
4749
5084
  })).sort((a, b) => b.total - a.total) };
4750
5085
  }
4751
- if (action === "failed") return { failed: list };
4752
- if (action === "list") return { entries: list };
5086
+ if (action === "failed") {
5087
+ const suggestions = list.length === 0 && args.urlSubstr ? urlSuggestions(entries, String(args.urlSubstr)) : [];
5088
+ return suggestions.length > 0 ? {
5089
+ failed: list,
5090
+ suggestions
5091
+ } : { failed: list };
5092
+ }
5093
+ if (action === "list") {
5094
+ const suggestions = list.length === 0 && args.urlSubstr ? urlSuggestions(entries, String(args.urlSubstr)) : [];
5095
+ return suggestions.length > 0 ? {
5096
+ entries: list,
5097
+ suggestions
5098
+ } : { entries: list };
5099
+ }
4753
5100
  throw new Error(`unknown network action: ${action}`);
4754
5101
  }
4755
5102
  //#endregion
@@ -5036,25 +5383,187 @@ function previewHighlight(page, selector, label) {
5036
5383
  }).catch(() => {});
5037
5384
  }
5038
5385
  /**
5386
+ * 常见认证/登录相关的 URL 特征正则,用于防止跳板页提前误判。
5387
+ */
5388
+ const AUTH_URL_PATTERN = /login|signin|sign-in|oauth|passport|sso|cas|challenge|captcha|verify|auth\b/i;
5389
+ /**
5390
+ * 等待人工登录底层执行(独立导出供测试注入 fake page)。
5391
+ * 协同机制:
5392
+ * 1. 挂起等待期间置入 instruction 至实时画面面板(setPauseInstruction),结束后统一清理;
5393
+ * 2. 存活标签感知:感知 context 内所有存活页面,原标签关闭时自动迁移至存活页,新标签出现时通知外层注册;
5394
+ * 3. 稳态判定:
5395
+ * - 显式 successSelector / successUrl 优先;
5396
+ * - 缺省时,检测 URL 离开登录页(非 AUTH_URL_PATTERN)、登录密码框消除、或 SPA 内容签名稳态变化,防范跳板页误判与 SPA 弹窗漏判。
5397
+ */
5398
+ async function runWaitForLoginOnPage(initialPage, args, onNewPageDetected) {
5399
+ const instruction = typeof args.instruction === "string" && args.instruction.trim() ? args.instruction.trim() : "请在浏览器窗口中完成登录";
5400
+ const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 12e4) || 12e4, 1e3), 6e5);
5401
+ const pollIntervalMs = Math.min(Math.max(Number(args.pollIntervalMs ?? 2e3) || 2e3, 100), 5e3);
5402
+ let currentPage = initialPage;
5403
+ let startUrl = "";
5404
+ try {
5405
+ startUrl = initialPage.url();
5406
+ } catch {}
5407
+ const checkHasPassword = async (p) => {
5408
+ try {
5409
+ return await p.evaluate(() => !!document.querySelector("input[type=\"password\"]"));
5410
+ } catch {
5411
+ return false;
5412
+ }
5413
+ };
5414
+ const getPageSig = async (p) => {
5415
+ try {
5416
+ return await p.evaluate(() => {
5417
+ const body = document.body;
5418
+ return `${location.href}|${body ? body.innerText.length : 0}|${document.querySelectorAll("input,button,a").length}`;
5419
+ });
5420
+ } catch {
5421
+ try {
5422
+ return p.url();
5423
+ } catch {
5424
+ return "";
5425
+ }
5426
+ }
5427
+ };
5428
+ const startHasPassword = await checkHasPassword(initialPage);
5429
+ const startSig = args.successSelector || args.successUrl ? "" : await getPageSig(initialPage);
5430
+ const startedAt = Date.now();
5431
+ let loggedIn = false;
5432
+ let detail = `等待登录超时(${timeoutMs}ms)`;
5433
+ setPauseInstruction(instruction);
5434
+ try {
5435
+ const deadline = startedAt + timeoutMs;
5436
+ while (Date.now() < deadline) {
5437
+ let candidatePages = [];
5438
+ try {
5439
+ const context = typeof currentPage.context === "function" ? currentPage.context() : null;
5440
+ if (context && typeof context.pages === "function") candidatePages = context.pages().filter((p) => {
5441
+ try {
5442
+ return !p.isClosed();
5443
+ } catch {
5444
+ return false;
5445
+ }
5446
+ });
5447
+ } catch {}
5448
+ let isCurrentClosed = false;
5449
+ try {
5450
+ isCurrentClosed = currentPage.isClosed();
5451
+ } catch {
5452
+ isCurrentClosed = true;
5453
+ }
5454
+ if (candidatePages.length > 0) {
5455
+ for (const p of candidatePages) if (p !== currentPage && onNewPageDetected) onNewPageDetected(p);
5456
+ if (isCurrentClosed) {
5457
+ currentPage = candidatePages[candidatePages.length - 1];
5458
+ isCurrentClosed = false;
5459
+ }
5460
+ }
5461
+ try {
5462
+ if (args.successSelector) {
5463
+ const targets = candidatePages.length > 0 ? candidatePages : isCurrentClosed ? [] : [currentPage];
5464
+ for (const p of targets) try {
5465
+ if (await p.locator(String(args.successSelector)).first().isVisible()) {
5466
+ loggedIn = true;
5467
+ currentPage = p;
5468
+ detail = `命中 successSelector: ${args.successSelector}`;
5469
+ break;
5470
+ }
5471
+ } catch {}
5472
+ } else if (args.successUrl) {
5473
+ const re = new RegExp(String(args.successUrl));
5474
+ const targets = candidatePages.length > 0 ? candidatePages : isCurrentClosed ? [] : [currentPage];
5475
+ for (const p of targets) try {
5476
+ const u = p.url();
5477
+ if (re.test(u)) {
5478
+ loggedIn = true;
5479
+ currentPage = p;
5480
+ detail = `命中 successUrl: ${args.successUrl}`;
5481
+ break;
5482
+ }
5483
+ } catch {}
5484
+ } else {
5485
+ const target = !isCurrentClosed ? currentPage : candidatePages[0];
5486
+ if (target) {
5487
+ let curUrl = "";
5488
+ try {
5489
+ curUrl = target.url();
5490
+ } catch {}
5491
+ if (curUrl && curUrl !== "about:blank") {
5492
+ if (curUrl !== startUrl) {
5493
+ if (!AUTH_URL_PATTERN.test(curUrl)) {
5494
+ loggedIn = true;
5495
+ currentPage = target;
5496
+ detail = `URL 离开登录页跳转至 ${curUrl}`;
5497
+ } else {
5498
+ const hasPwd = await checkHasPassword(target);
5499
+ if (startHasPassword && !hasPwd) {
5500
+ loggedIn = true;
5501
+ currentPage = target;
5502
+ detail = `登录密码输入框已消除,页面跳转至 ${curUrl}`;
5503
+ }
5504
+ }
5505
+ } else {
5506
+ const hasPwd = await checkHasPassword(target);
5507
+ if (startHasPassword && !hasPwd) {
5508
+ loggedIn = true;
5509
+ currentPage = target;
5510
+ detail = "登录密码输入框已消除(SPA 登录完成)";
5511
+ } else if (await getPageSig(target) !== startSig && (!startHasPassword || !hasPwd)) {
5512
+ loggedIn = true;
5513
+ currentPage = target;
5514
+ detail = "页面内容签名变更(SPA 登录完成)";
5515
+ }
5516
+ }
5517
+ }
5518
+ }
5519
+ }
5520
+ } catch (e) {
5521
+ detail = `检查登录状态出错: ${e instanceof Error ? e.message : String(e)}`;
5522
+ }
5523
+ if (loggedIn) break;
5524
+ try {
5525
+ await currentPage.waitForTimeout(pollIntervalMs);
5526
+ } catch {
5527
+ await sleep$1(pollIntervalMs);
5528
+ }
5529
+ }
5530
+ } finally {
5531
+ clearPauseInstruction();
5532
+ }
5533
+ return {
5534
+ loggedIn,
5535
+ waitedMs: Date.now() - startedAt,
5536
+ detail,
5537
+ targetPage: currentPage
5538
+ };
5539
+ }
5540
+ /**
5039
5541
  * 等待人工登录完成(配合 headless:false 使用):agent 打开登录页后调用本工具,
5040
- * 提示用户在浏览器窗口中完成登录(输入/扫码/双因素)。基于通用条件引擎:
5041
- * successSelector / successUrl 指定完成条件,两者都不给则以 URL 跳转视为完成。
5042
- * 超时返回 loggedIn:false(不抛错)。
5542
+ * 提示用户在浏览器窗口中完成登录(输入/扫码/双因素)。支持 instruction 提示语
5543
+ * 与右下角实时画面面板联动显示横幅,支持多标签感知与 SPA 签名自适应判定。
5544
+ * 超时返回 loggedIn:false(附 waitedMs 与 detail,不抛错)。
5043
5545
  */
5044
5546
  async function waitForLoginTool(config, args) {
5045
5547
  const page = await getPage(config);
5046
- const startUrl = page.url();
5047
- let condition;
5048
- if (args.successSelector) condition = {
5049
- selector: String(args.successSelector),
5050
- state: "visible"
5051
- };
5052
- else if (args.successUrl) condition = { url: String(args.successUrl) };
5053
- else condition = { eval: `location.href !== ${JSON.stringify(startUrl)} && location.href !== "about:blank"` };
5054
- const timeoutMs = Math.min(Math.max(Number(args.timeoutMs ?? 12e4) || 12e4, 5e3), 6e5);
5548
+ const state = stateOf();
5549
+ const result = await runWaitForLoginOnPage(page, args, (newPage) => {
5550
+ if (![...state.pages.values()].includes(newPage)) {
5551
+ attachPage(newPage, profileConfig(config, currentProfile));
5552
+ registerTabPage(state, newPage);
5553
+ }
5554
+ });
5555
+ if (result.targetPage !== page) {
5556
+ for (const [id, p] of state.pages) if (p === result.targetPage) {
5557
+ state.activeId = id;
5558
+ break;
5559
+ }
5560
+ }
5561
+ const identity = await pageIdentity(result.targetPage);
5055
5562
  return {
5056
- loggedIn: (await pollCondition(page, condition, timeoutMs, 2e3)).ok,
5057
- ...await pageIdentity(page)
5563
+ loggedIn: result.loggedIn,
5564
+ waitedMs: result.waitedMs,
5565
+ detail: result.detail,
5566
+ ...identity
5058
5567
  };
5059
5568
  }
5060
5569
  /**
@@ -5996,7 +6505,7 @@ function compileToPlaywrightSpec(steps, options = {}) {
5996
6505
  else if (args.selector) {
5997
6506
  const state = args.state || "visible";
5998
6507
  lines.push(` await page.locator(${JSON.stringify(args.selector)}).waitFor({ state: ${JSON.stringify(state)} });`);
5999
- }
6508
+ } else if (args.condition?.bridge !== void 0 || args.bridge !== void 0) lines.push(` // bridge 条件请在接入 vite-plugin-dsh-bridge 的环境手动断言: await __DSH_BRIDGE__.<method>(...)`);
6000
6509
  break;
6001
6510
  case "browser_assert": {
6002
6511
  const sel = args.selector || "body";
@@ -6004,6 +6513,9 @@ function compileToPlaywrightSpec(steps, options = {}) {
6004
6513
  else if (args.visible !== void 0) {
6005
6514
  if (args.visible) lines.push(` await expect(page.locator(${JSON.stringify(sel)})).toBeVisible();`);
6006
6515
  else lines.push(` await expect(page.locator(${JSON.stringify(sel)})).toBeHidden();`);
6516
+ } else if (args.condition?.bridge !== void 0 || args.bridge !== void 0) {
6517
+ const bridge = args.condition?.bridge ?? args.bridge;
6518
+ lines.push(` // bridge 断言请在接入 vite-plugin-dsh-bridge 的环境手动执行: const v = await __DSH_BRIDGE__.${String(bridge?.method ?? "method")}(); expect(v).toBeTruthy();`);
6007
6519
  }
6008
6520
  break;
6009
6521
  }
@@ -6658,7 +7170,7 @@ function getNavigationTools(config, timeout, stepImpls) {
6658
7170
  return [
6659
7171
  definePlainTool({
6660
7172
  name: "browser_open",
6661
- description: "在共享浏览器中打开一个 URL。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
7173
+ description: "在共享浏览器中打开一个 URL。活动标签已关闭或崩溃时自动新建标签重试(返回标 recovered: 'new-tab')。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
6662
7174
  parameters: {
6663
7175
  type: "object",
6664
7176
  properties: {
@@ -6689,7 +7201,11 @@ function getNavigationTools(config, timeout, stepImpls) {
6689
7201
  properties: {
6690
7202
  url: { type: "string" },
6691
7203
  title: { type: "string" },
6692
- status: { type: "string" }
7204
+ status: { type: "string" },
7205
+ recovered: {
7206
+ type: "string",
7207
+ description: "活动标签已死自动换新页时为 'new-tab'。"
7208
+ }
6693
7209
  },
6694
7210
  required: [
6695
7211
  "url",
@@ -6798,7 +7314,7 @@ function getNavigationTools(config, timeout, stepImpls) {
6798
7314
  }),
6799
7315
  definePlainTool({
6800
7316
  name: "browser_tabs",
6801
- description: "管理浏览器多标签页:list 列出所有标签,new 新建(可选带 url),switch 按 id 或 index 切换,close 关闭指定标签。id 来自 list 结果。",
7317
+ description: "管理浏览器多标签页:list 列出所有标签(每项含 createdAt 注册时间;已关闭的僵尸页自动摘除),new 新建(可选带 url),switch 按 id 或 index 切换,close 关闭指定标签。id 来自 list 结果。",
6802
7318
  parameters: {
6803
7319
  type: "object",
6804
7320
  properties: {
@@ -6975,21 +7491,21 @@ function getInteractionTools(config, timeout, stepImpls) {
6975
7491
  return [
6976
7492
  definePlainTool({
6977
7493
  name: "browser_click",
6978
- description: "点击页面元素:selector(CSS)、ref(来自 browser_snapshot mode:'aria')、what(意图描述,如 '登录'/'提交订单',页内打分匹配并自动注册 ref)或 mark(SoM 标徽编号,看图定位)四选一。也可通过 row(如 'TASK-1002') 与 action(如 '编辑') 直接定位表格中某行操作按钮。what 零命中时报错并附最相近候选与 SoM 标徽截图。",
7494
+ description: "点击页面元素:selector(CSS)、ref(来自 browser_snapshot mode:'aria')、what(意图描述,如 '登录'/'提交订单',页内打分匹配并自动注册 ref)或 mark(SoM 标徽编号,看图定位)四选一——同时传多个会直接报参数错误;容器内语义定位用 what + scope。也可通过 row(如 'TASK-1002') 与 action(如 '编辑') 直接定位表格中某行操作按钮(row 可组合 selector 限定行内目标)。what 零命中时报错并附最相近候选与 SoM 标徽截图。",
6979
7495
  parameters: {
6980
7496
  type: "object",
6981
7497
  properties: {
6982
7498
  selector: {
6983
7499
  type: "string",
6984
- description: "CSS 选择器。"
7500
+ description: "CSS 选择器(与 ref/mark/what 互斥)。"
6985
7501
  },
6986
7502
  ref: {
6987
7503
  type: "string",
6988
- description: "aria 快照的 ref(如 'e3');导航后失效。"
7504
+ description: "aria 快照的 ref(如 'e3');导航后失效。与 selector/mark/what 互斥。"
6989
7505
  },
6990
7506
  what: {
6991
7507
  type: "string",
6992
- description: "意图描述(元素名称/文本,如 '登录'):页内打分语义定位,免快照直达;零命中返回最相近候选。"
7508
+ description: "意图描述(元素名称/文本,如 '登录'):页内打分语义定位,免快照直达;零命中返回最相近候选。与 selector/ref/mark 互斥;容器内定位配合 scope。"
6993
7509
  },
6994
7510
  scope: {
6995
7511
  type: "string",
@@ -6997,11 +7513,11 @@ function getInteractionTools(config, timeout, stepImpls) {
6997
7513
  },
6998
7514
  mark: {
6999
7515
  type: "integer",
7000
- description: "SoM 标徽编号(browser_screenshot { som: true } 返回 somMarks 的 id);多模态看图定位的落点,导航后失效。"
7516
+ description: "SoM 标徽编号(browser_screenshot { som: true } 返回 somMarks 的 id);多模态看图定位的落点,导航后失效。与 selector/ref/what 互斥。"
7001
7517
  },
7002
7518
  row: {
7003
7519
  type: "string",
7004
- description: "表格行文本标识,如 'TASK-1002',用于行级定向操作。"
7520
+ description: "表格行文本标识,如 'TASK-1002',用于行级定向操作(可组合 selector 限定行内目标,不与 what/ref/mark 同用)。"
7005
7521
  },
7006
7522
  action: {
7007
7523
  type: "string",
@@ -7019,6 +7535,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7019
7535
  type: "boolean",
7020
7536
  description: "点击效果验证:收集点击后的 DOM 变化摘要/URL 变化/新增网络请求;what 多候选首击无动静时自动换次候选。"
7021
7537
  },
7538
+ strict: {
7539
+ type: "boolean",
7540
+ description: "多命中即抛错(对齐 Playwright strict),要求定位唯一;默认 false 操作第 1 个并在返回中提示命中数。"
7541
+ },
7022
7542
  timeoutMs: { type: "integer" }
7023
7543
  },
7024
7544
  additionalProperties: false
@@ -7031,7 +7551,23 @@ function getInteractionTools(config, timeout, stepImpls) {
7031
7551
  url: { type: "string" },
7032
7552
  title: { type: "string" },
7033
7553
  effect: { type: "object" },
7034
- effectHint: { type: "string" }
7554
+ effectHint: { type: "string" },
7555
+ matchedCount: {
7556
+ type: "integer",
7557
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7558
+ },
7559
+ visibleCount: {
7560
+ type: "integer",
7561
+ description: "命中元素中的可见数量。"
7562
+ },
7563
+ hint: {
7564
+ type: "string",
7565
+ description: "matchedCount>1 时的消歧指引。"
7566
+ },
7567
+ viaBridge: {
7568
+ type: "boolean",
7569
+ description: "what 未命中时经页面 __DSH_BRIDGE__(vite-plugin-dsh-bridge)兜底命中。"
7570
+ }
7035
7571
  },
7036
7572
  required: [
7037
7573
  "clicked",
@@ -7045,18 +7581,21 @@ function getInteractionTools(config, timeout, stepImpls) {
7045
7581
  }),
7046
7582
  definePlainTool({
7047
7583
  name: "browser_type",
7048
- description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')定位目标。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。",
7584
+ description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。",
7049
7585
  parameters: {
7050
7586
  type: "object",
7051
7587
  properties: {
7052
- selector: { type: "string" },
7588
+ selector: {
7589
+ type: "string",
7590
+ description: "CSS 选择器(与 ref/what 互斥)。"
7591
+ },
7053
7592
  ref: {
7054
7593
  type: "string",
7055
- description: "aria 快照的 ref(如 'e2')。"
7594
+ description: "aria 快照的 ref(如 'e2')。与 selector/what 互斥。"
7056
7595
  },
7057
7596
  what: {
7058
7597
  type: "string",
7059
- description: "意图描述(元素名称/label/placeholder,如 '用户名'):页内打分语义定位。"
7598
+ description: "意图描述(元素名称/label/placeholder,如 '用户名'):页内打分语义定位。与 selector/ref 互斥;容器内定位配合 scope。"
7060
7599
  },
7061
7600
  scope: {
7062
7601
  type: "string",
@@ -7073,6 +7612,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7073
7612
  type: "boolean",
7074
7613
  description: "使用高斯间隔拟人化逐字输入。"
7075
7614
  },
7615
+ strict: {
7616
+ type: "boolean",
7617
+ description: "多命中即抛错(对齐 Playwright strict);多命中或 strict 时不再 force 降级点击,遮罩拦截会直接暴露。"
7618
+ },
7076
7619
  delayMs: { type: "integer" },
7077
7620
  timeoutMs: { type: "integer" }
7078
7621
  },
@@ -7087,7 +7630,19 @@ function getInteractionTools(config, timeout, stepImpls) {
7087
7630
  selector: { type: "string" },
7088
7631
  submit: { type: "boolean" },
7089
7632
  url: { type: "string" },
7090
- title: { type: "string" }
7633
+ title: { type: "string" },
7634
+ matchedCount: {
7635
+ type: "integer",
7636
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7637
+ },
7638
+ visibleCount: {
7639
+ type: "integer",
7640
+ description: "命中元素中的可见数量。"
7641
+ },
7642
+ hint: {
7643
+ type: "string",
7644
+ description: "matchedCount>1 时的消歧指引。"
7645
+ }
7091
7646
  },
7092
7647
  required: [
7093
7648
  "typed",
@@ -7103,7 +7658,7 @@ function getInteractionTools(config, timeout, stepImpls) {
7103
7658
  }),
7104
7659
  definePlainTool({
7105
7660
  name: "browser_press",
7106
- description: "按键:有 selector 或 ref 时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
7661
+ description: "按键:有 selector、ref 或 what(三选一,互斥)时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
7107
7662
  parameters: {
7108
7663
  type: "object",
7109
7664
  properties: {
@@ -7111,14 +7666,21 @@ function getInteractionTools(config, timeout, stepImpls) {
7111
7666
  type: "string",
7112
7667
  description: "按键名(Playwright 键盘键名)。"
7113
7668
  },
7114
- selector: { type: "string" },
7669
+ selector: {
7670
+ type: "string",
7671
+ description: "CSS 选择器(与 ref/what 互斥)。"
7672
+ },
7115
7673
  ref: {
7116
7674
  type: "string",
7117
- description: "aria 快照的 ref。"
7675
+ description: "aria 快照的 ref。与 selector/what 互斥。"
7118
7676
  },
7119
7677
  what: {
7120
7678
  type: "string",
7121
- description: "意图描述:先聚焦该元素再按键(页内打分语义定位)。"
7679
+ description: "意图描述:先聚焦该元素再按键(页内打分语义定位)。与 selector/ref 互斥。"
7680
+ },
7681
+ strict: {
7682
+ type: "boolean",
7683
+ description: "多命中即抛错(对齐 Playwright strict)。"
7122
7684
  },
7123
7685
  timeoutMs: { type: "integer" }
7124
7686
  },
@@ -7131,7 +7693,20 @@ function getInteractionTools(config, timeout, stepImpls) {
7131
7693
  properties: {
7132
7694
  pressed: { type: "string" },
7133
7695
  url: { type: "string" },
7134
- title: { type: "string" }
7696
+ title: { type: "string" },
7697
+ warning: { type: "string" },
7698
+ matchedCount: {
7699
+ type: "integer",
7700
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7701
+ },
7702
+ visibleCount: {
7703
+ type: "integer",
7704
+ description: "命中元素中的可见数量。"
7705
+ },
7706
+ hint: {
7707
+ type: "string",
7708
+ description: "matchedCount>1 时的消歧指引。"
7709
+ }
7135
7710
  },
7136
7711
  required: [
7137
7712
  "pressed",
@@ -7145,21 +7720,25 @@ function getInteractionTools(config, timeout, stepImpls) {
7145
7720
  }),
7146
7721
  definePlainTool({
7147
7722
  name: "browser_select",
7148
- description: "下拉选择原语:原生 <select> 与组件库下拉(antd / element-plus / ARIA listbox)通用。selector 或 ref 指向下拉触发框,option 为选项文本(默认包含匹配,exact=true 精确)。多选下拉传 clear:true 可在选择前先清空旧标签实现替换语义。找不到选项时报错并列出可见选项,无需再 browser_eval 探查。",
7723
+ description: "下拉选择原语:原生 <select> 与组件库下拉(antd / element-plus / ARIA listbox)通用。selector、ref 或 what(三选一,互斥)指向下拉触发框,option 为选项文本(默认包含匹配,exact=true 精确)。多选下拉传 clear:true 可在选择前先清空旧标签实现替换语义。找不到选项时报错并列出可见选项,无需再 browser_eval 探查。",
7149
7724
  parameters: {
7150
7725
  type: "object",
7151
7726
  properties: {
7152
7727
  selector: {
7153
7728
  type: "string",
7154
- description: "下拉触发框的 CSS 选择器(如 #rc_select_1)。"
7729
+ description: "下拉触发框的 CSS 选择器(如 #rc_select_1);与 ref/what 互斥。"
7155
7730
  },
7156
7731
  ref: {
7157
7732
  type: "string",
7158
- description: "aria 快照的 ref。"
7733
+ description: "aria 快照的 ref。与 selector/what 互斥。"
7159
7734
  },
7160
7735
  what: {
7161
7736
  type: "string",
7162
- description: "意图描述(下拉的 label/名称,如 '所属部门'):页内打分语义定位触发框。"
7737
+ description: "意图描述(下拉的 label/名称,如 '所属部门'):页内打分语义定位触发框。与 selector/ref 互斥;容器内定位配合 scope。"
7738
+ },
7739
+ scope: {
7740
+ type: "string",
7741
+ description: "配合 what 限定扫描范围的容器选择器。"
7163
7742
  },
7164
7743
  option: {
7165
7744
  type: "string",
@@ -7173,6 +7752,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7173
7752
  type: "boolean",
7174
7753
  description: "是否在选择前先清空已有选中项(对于多选下拉,替换而非叠加旧选项)。"
7175
7754
  },
7755
+ strict: {
7756
+ type: "boolean",
7757
+ description: "多命中即抛错(对齐 Playwright strict)。"
7758
+ },
7176
7759
  timeoutMs: { type: "integer" }
7177
7760
  },
7178
7761
  required: ["option"],
@@ -7185,7 +7768,19 @@ function getInteractionTools(config, timeout, stepImpls) {
7185
7768
  selected: { type: "string" },
7186
7769
  selector: { type: "string" },
7187
7770
  url: { type: "string" },
7188
- title: { type: "string" }
7771
+ title: { type: "string" },
7772
+ matchedCount: {
7773
+ type: "integer",
7774
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7775
+ },
7776
+ visibleCount: {
7777
+ type: "integer",
7778
+ description: "命中元素中的可见数量。"
7779
+ },
7780
+ hint: {
7781
+ type: "string",
7782
+ description: "matchedCount>1 时的消歧指引。"
7783
+ }
7189
7784
  },
7190
7785
  required: [
7191
7786
  "selected",
@@ -7841,7 +8436,7 @@ function getTestingTools(config, timeout, stepImpls) {
7841
8436
  }),
7842
8437
  definePlainTool({
7843
8438
  name: "browser_wait_for",
7844
- description: "通用显式等待(测试同步原语):轮询直到条件成立或超时抛错。condition 格式(可放在 condition 对象内,也可直接平铺于顶层参数): 1) {selector:'#btn', state:'visible|hidden|attached|detached'}; 2) {ref:'e1', state}; 3) {row:'TASK-1002', action:'编辑', state}; 4) {url:'正则'}; 5) {text:'子串'}; 6) {count:{selector,op,value}}; 7) {eval:'JS 表达式' (支持纯表达式或 return 语句)}。用于等页面渲染、等请求完成、等元素出现。",
8439
+ description: "通用显式等待(测试同步原语):轮询直到条件成立或超时抛错。condition 格式(可放在 condition 对象内,也可直接平铺于顶层参数): 1) {selector:'#btn', state:'visible|hidden|attached|detached'}; 2) {ref:'e1', state}; 3) {row:'TASK-1002', action:'编辑', state}; 4) {url:'正则'}; 5) {text:'子串'}; 6) {count:{selector,op,value}}; 7) {eval:'JS 表达式' (支持纯表达式或 return 语句)}; 8) {bridge:{method:'桥方法名', args?:[], expect?:期望值 / expectContains:'子串'}} —— 页内调用 __DSH_BRIDGE__(vite-plugin-dsh-bridge)方法并断言返回(缺省判 truthy;expect 深比较),让桥取数与宿主等待无缝衔接。用于等页面渲染、等请求完成、等元素出现。",
7845
8440
  parameters: {
7846
8441
  type: "object",
7847
8442
  properties: {
@@ -7881,13 +8476,13 @@ function getTestingTools(config, timeout, stepImpls) {
7881
8476
  }),
7882
8477
  definePlainTool({
7883
8478
  name: "browser_assert",
7884
- description: "结构化断言(测试核心):condition 在 timeoutMs 内成立则通过(条件格式同 browser_wait_for,支持 selector/ref/row+action/url/text/count/eval,亦可直接平铺在顶层参数)。失败时自动截图存证据目录(assert-fail-*.png)并抛错,错误消息含原因与截图路径。另支持视觉回归:传 visual={baseline, threshold?, maxDiffRatio?, mask?} 与基线截图做像素比对(browser_screenshot 的 baseline 建立基线;mask 为忽略的选择器数组,如时钟/广告位),失败时产出当前截图与红标 diff 图。适合断言登录成功、关键元素出现、接口返回、文本正确、页面无视觉回归。",
8479
+ description: "结构化断言(测试核心):condition 在 timeoutMs 内成立则通过(条件格式同 browser_wait_for,支持 selector/ref/row+action/url/text/count/eval/bridge,亦可直接平铺在顶层参数)。bridge 条件 {bridge:{method,args?,expect?/expectContains?}} 页内调用 __DSH_BRIDGE__(vite-plugin-dsh-bridge)方法断言返回——桥取数、宿主断言留证(失败自动截图)。失败时自动截图存证据目录(assert-fail-*.png)并抛错,错误消息含原因与截图路径。另支持视觉回归:传 visual={baseline, threshold?, maxDiffRatio?, mask?} 与基线截图做像素比对(browser_screenshot 的 baseline 建立基线;mask 为忽略的选择器数组,如时钟/广告位),失败时产出当前截图与红标 diff 图。适合断言登录成功、关键元素出现、接口返回、文本正确、页面无视觉回归。",
7885
8480
  parameters: {
7886
8481
  type: "object",
7887
8482
  properties: {
7888
8483
  condition: {
7889
8484
  type: "object",
7890
- description: "断言条件(同 browser_wait_for,另支持 {ref, state})。"
8485
+ description: "断言条件(同 browser_wait_for,另支持 {ref, state} 与 {bridge:{method,args?,expect?}})。"
7891
8486
  },
7892
8487
  visual: {
7893
8488
  type: "object",
@@ -7942,7 +8537,7 @@ function getTestingTools(config, timeout, stepImpls) {
7942
8537
  },
7943
8538
  urlSubstr: {
7944
8539
  type: "string",
7945
- description: "URL 子串过滤(list/failed)。"
8540
+ description: "URL 子串过滤(list/failed,大小写不敏感;零命中时附 suggestions 相近 URL 候选,防误判接口未调用)。"
7946
8541
  },
7947
8542
  method: {
7948
8543
  type: "string",
@@ -7985,7 +8580,12 @@ function getTestingTools(config, timeout, stepImpls) {
7985
8580
  },
7986
8581
  matched: { type: "object" },
7987
8582
  waitMs: { type: "integer" },
7988
- cleared: { type: "boolean" }
8583
+ cleared: { type: "boolean" },
8584
+ suggestions: {
8585
+ type: "array",
8586
+ items: { type: "string" },
8587
+ description: "urlSubstr 零命中时的 top5 相近 URL 候选。"
8588
+ }
7989
8589
  },
7990
8590
  required: []
7991
8591
  },
@@ -8010,10 +8610,14 @@ function getCollaborationTools(config, timeout, stepImpls) {
8010
8610
  return [
8011
8611
  definePlainTool({
8012
8612
  name: "browser_wait_for_login",
8013
- description: "等待人工登录完成。agent 在登录页调用本工具后,提示用户在浏览器窗口中完成登录(有头模式 headless:false 时用户可直接操作;配 userDataDir 登录态会持久化)。成功条件三选一:successSelector(登录后出现的元素,如 '#avatar')或 successUrl(登录后 URL 正则,如 'https://\\\\.app\\\\.example\\\\.com')都不给则 URL 发生变化即视为完成。轮询间隔 2 秒,超时返回 loggedIn:false。",
8613
+ description: "等待人工登录完成。agent 在登录页调用本工具后,提示用户在浏览器窗口中完成登录(有头模式 headless:false 时用户可直接操作;配 userDataDir 登录态会持久化)。支持 instruction 提示语(将显示在右下角实时画面面板);成功条件:优先取 successSelector(登录后出现的元素,如 '#avatar')或 successUrl(登录后 URL 正则);两者未指定时,结合 URL 变化、登录特征消除(密码框等)与页面签名稳态综合判定。轮询间隔 2 秒,超时返回 loggedIn:false(附 waitedMs 与 detail)。",
8014
8614
  parameters: {
8015
8615
  type: "object",
8016
8616
  properties: {
8617
+ instruction: {
8618
+ type: "string",
8619
+ description: "可选:给用户看的提示说明(默认「请在浏览器窗口中完成登录」)。"
8620
+ },
8017
8621
  timeoutMs: {
8018
8622
  type: "integer",
8019
8623
  description: "等待上限(毫秒,默认 120000,上限 600000)。"
@@ -8034,11 +8638,15 @@ function getCollaborationTools(config, timeout, stepImpls) {
8034
8638
  additionalProperties: false,
8035
8639
  properties: {
8036
8640
  loggedIn: { type: "boolean" },
8641
+ waitedMs: { type: "integer" },
8642
+ detail: { type: "string" },
8037
8643
  url: { type: "string" },
8038
8644
  title: { type: "string" }
8039
8645
  },
8040
8646
  required: [
8041
8647
  "loggedIn",
8648
+ "waitedMs",
8649
+ "detail",
8042
8650
  "url",
8043
8651
  "title"
8044
8652
  ]