dsh-browser-view 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -683,6 +683,25 @@ function __walkerCore() {
683
683
  visible: isVisible(el)
684
684
  };
685
685
  }
686
+ /** 疑似可点击 generic 元素(自定义组件的 span+click 等):无 ARIA 角色或显式
687
+ * presentational(presentation/none)但具备 cursor:pointer / onclick 属性 /
688
+ * tabindex≥0 特征且有非空文本(≤60 字符)。仅供扫描语义消费(what 语义扫描与
689
+ * SoM 标徽共用,防判定分叉);aria 快照不纳入(避免噪音)——这是记录在案的
690
+ * 设计分叉,见产品文档 03。 */
691
+ function isProbablyClickable(el) {
692
+ const role = roleOf(el);
693
+ if (role !== "" && role !== "presentation" && role !== "none") return false;
694
+ const text = (el.innerText || el.textContent || "").trim();
695
+ if (text === "" || text.length > 60) return false;
696
+ if (el.hasAttribute("onclick")) return true;
697
+ const ti = el.tabIndex;
698
+ if (typeof ti === "number" && ti >= 0) return true;
699
+ try {
700
+ return getComputedStyle(el).cursor === "pointer";
701
+ } catch {
702
+ return false;
703
+ }
704
+ }
686
705
  return {
687
706
  INTERACTIVE,
688
707
  STRUCTURAL,
@@ -694,7 +713,8 @@ function __walkerCore() {
694
713
  isElementInViewport,
695
714
  describeElement,
696
715
  composedParent,
697
- deepChildren
716
+ deepChildren,
717
+ isProbablyClickable
698
718
  };
699
719
  }
700
720
  /** walker 核心的序列化源码(模块加载期取一次;tsdown/vitest 均原样保留)。 */
@@ -1650,7 +1670,7 @@ async function fillFormFieldHelper(page, field, scope) {
1650
1670
  * 多 token(空格/中英混排拆分)按 AND 命中 —— 单 token 行为与旧子串匹配等价。
1651
1671
  */
1652
1672
  const scanInteractive = injectWalker((payload, w) => {
1653
- const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, deepChildren, composedParent } = w;
1673
+ const { INTERACTIVE, STRUCTURAL, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, deepChildren, composedParent, isProbablyClickable } = w;
1654
1674
  function resolveScopeRoot(scopeStr) {
1655
1675
  if (!scopeStr) return document.body;
1656
1676
  const wantsVisible = /:visible\b/.test(scopeStr);
@@ -1759,6 +1779,39 @@ const scanInteractive = injectWalker((payload, w) => {
1759
1779
  });
1760
1780
  continue;
1761
1781
  }
1782
+ if (isVisible(el) && isProbablyClickable(el)) {
1783
+ const text = (el.innerText || el.textContent || "").trim().slice(0, 60);
1784
+ const n = text.toLowerCase();
1785
+ const nStr = n.replace(/\s+/g, "");
1786
+ const legacyHit = payload.exact ? n === q || nStr === qStripped : n.includes(q) || qStripped !== "" && nStr.includes(qStripped);
1787
+ let tokenScore = 0;
1788
+ let tokenHit = true;
1789
+ for (const t of tokens) {
1790
+ const tStr = t.replace(/\s+/g, "");
1791
+ if (n.includes(t) || tStr !== "" && nStr.includes(tStr)) tokenScore += 3;
1792
+ else {
1793
+ tokenHit = false;
1794
+ break;
1795
+ }
1796
+ }
1797
+ if (legacyHit && n === q) tokenScore += 50;
1798
+ const hit = payload.exact ? legacyHit : legacyHit || tokens.length > 0 && tokenHit;
1799
+ if (candidates.length < 50) candidates.push({
1800
+ role: "clickable",
1801
+ name: text,
1802
+ selector: cssPath(el)
1803
+ });
1804
+ if (hit && hits.length < 100) hits.push({
1805
+ role: "clickable",
1806
+ name: text,
1807
+ selector: cssPath(el),
1808
+ text,
1809
+ visible: true,
1810
+ inDialog: dialogSel !== "" ? Boolean(el.closest(dialogSel)) : false,
1811
+ score: (legacyHit ? 100 : 0) + tokenScore - 200,
1812
+ ancestors: ancestorsOf(el)
1813
+ });
1814
+ }
1762
1815
  for (const c of deepChildren(el)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) stack.push(c);
1763
1816
  }
1764
1817
  hits.sort((a, b) => Number(b.inDialog) - Number(a.inDialog) || b.score - a.score);
@@ -1838,7 +1891,7 @@ async function resolveSemanticTarget(page, what, opts = {}) {
1838
1891
  * 同一 walker 语义。上限 300 条,超出截断(超大页面重锚定按命中部分处理)。
1839
1892
  */
1840
1893
  const scanAllInteractive = injectWalker((payload, w) => {
1841
- const { INTERACTIVE, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren } = w;
1894
+ const { INTERACTIVE, SKIP_TAGS, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren, isProbablyClickable } = w;
1842
1895
  const out = [];
1843
1896
  let truncated = false;
1844
1897
  const snapScroll = {
@@ -1882,6 +1935,24 @@ const scanAllInteractive = injectWalker((payload, w) => {
1882
1935
  });
1883
1936
  continue;
1884
1937
  }
1938
+ if (out.length < 300 && isVisible(el) && isProbablyClickable(el)) {
1939
+ const text = (el.innerText || el.textContent || "").trim();
1940
+ const rect = el.getBoundingClientRect();
1941
+ out.push({
1942
+ role: "clickable",
1943
+ name: text.slice(0, 60),
1944
+ selector: cssPath(el),
1945
+ ...isElementInViewport(el, 0) ? {
1946
+ rect: {
1947
+ x: Math.round(rect.left),
1948
+ y: Math.round(rect.top),
1949
+ w: Math.round(rect.width),
1950
+ h: Math.round(rect.height)
1951
+ },
1952
+ scroll: snapScroll
1953
+ } : {}
1954
+ });
1955
+ }
1885
1956
  for (const c of deepChildren(el)) if (!SKIP_TAGS.has(c.tagName.toLowerCase())) stack.push(c);
1886
1957
  }
1887
1958
  return {
@@ -1964,6 +2035,18 @@ function nameSimilar(a, b) {
1964
2035
  * 具备路径防漂移校验与 getByRole 自愈降级能力。
1965
2036
  */
1966
2037
  async function resolveTargetLocator(page, args) {
2038
+ {
2039
+ const present = [];
2040
+ if (args.ref !== void 0 && args.ref !== "") present.push("ref");
2041
+ if (args.mark !== void 0) present.push("mark");
2042
+ if (typeof args.what === "string" && args.what.trim() !== "") present.push("what");
2043
+ if (args.selector !== void 0 && args.selector !== "") present.push("selector");
2044
+ if ((args.rowText !== void 0 || args.row !== void 0) && present.some((k) => k !== "selector")) {
2045
+ const offending = present.filter((k) => k !== "selector");
2046
+ throw new Error(`定位参数冲突: row/rowText 与 ${offending.join("、")} 不能同时使用(行级定位仅可组合 selector 限定行内目标);在行内按意图找按钮请用 row + action/actionText`);
2047
+ }
2048
+ if (present.length >= 2) throw new Error(`定位参数冲突: 同时传了 ${present.join(" 与 ")} —— ref/mark/what/selector 四选一;若要在容器内语义定位,请用 what + scope(容器选择器)`);
2049
+ }
1967
2050
  if (args.ref !== void 0 && args.ref !== "") {
1968
2051
  const ref = String(args.ref);
1969
2052
  const entry = pageRef(page, ref);
@@ -2159,6 +2242,29 @@ async function resolveTargetLocator(page, args) {
2159
2242
  visibleCount: 1
2160
2243
  };
2161
2244
  }
2245
+ {
2246
+ const bridgeHit = await withTimeout(page.evaluate(async (q) => {
2247
+ const b = window.__DSH_BRIDGE__;
2248
+ if (b === void 0 || typeof b.resolveActionTarget !== "function") return null;
2249
+ const targets = await b.resolveActionTarget({ query: q });
2250
+ if (!Array.isArray(targets) || targets.length === 0) return null;
2251
+ const t = targets[0];
2252
+ if (t === null || typeof t !== "object" || typeof t.selector !== "string" || t.selector === "") return null;
2253
+ return { selector: t.selector };
2254
+ }, what), 2500, "bridge 兜底").catch(() => null);
2255
+ const bridgeSelector = bridgeHit !== null && typeof bridgeHit === "object" && typeof bridgeHit.selector === "string" && bridgeHit.selector !== "" ? bridgeHit.selector : null;
2256
+ if (bridgeSelector !== null) {
2257
+ const loc = page.locator(bridgeSelector).first();
2258
+ if (await withTimeout(loc.count(), 1500, "bridge 兜底定位").catch(() => 0) > 0) return {
2259
+ locator: loc,
2260
+ container: page,
2261
+ isIFrame: false,
2262
+ matchedCount: 1,
2263
+ visibleCount: 1,
2264
+ viaBridge: true
2265
+ };
2266
+ }
2267
+ }
2162
2268
  const err = new Error(semanticMissMessage(res));
2163
2269
  err.semanticMiss = res;
2164
2270
  err.locationFailure = {
@@ -2756,7 +2862,7 @@ async function launchProfile(name, config) {
2756
2862
  ...options,
2757
2863
  headless: resolved.headless ?? false
2758
2864
  });
2759
- for (const existing of context.pages()) existing.close().catch(() => {});
2865
+ await Promise.race([Promise.all(context.pages().map((p) => p.close().catch(() => {}))), new Promise((r) => setTimeout(r, 3e3))]);
2760
2866
  state.browser = null;
2761
2867
  state.context = context;
2762
2868
  state.persistent = true;
@@ -2781,11 +2887,30 @@ const namedContexts = /* @__PURE__ */ new Map();
2781
2887
  function namedContextsOf() {
2782
2888
  return namedContexts;
2783
2889
  }
2784
- /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。 */
2890
+ /** 同步摘除已关闭页面(手动关标签/渲染崩溃的僵尸句柄)。
2891
+ * activeId 指向被摘页时置 null 而非自动迁移——静默换页对 click/type 是错页执行,
2892
+ * 由 getPage 报错引导显式恢复(switch / browser_open 自愈)。 */
2893
+ function pruneClosedPages(state) {
2894
+ for (const [id, page] of state.pages) if (page.isClosed()) {
2895
+ state.pages.delete(id);
2896
+ state.tabMeta?.delete(id);
2897
+ if (state.activeId === id) state.activeId = null;
2898
+ }
2899
+ }
2900
+ /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。
2901
+ * 唯一注册路径:同时挂 close 监听幂等摘除外部关闭的页(否则僵尸句柄会永久占用
2902
+ * activeId,所有工具连报 "Target page...closed" 而 tabs list 仍显示存活)。 */
2785
2903
  function registerTabPage(state, page) {
2786
2904
  const id = state.counter++;
2787
2905
  state.pages.set(id, page);
2788
2906
  state.activeId = id;
2907
+ (state.tabMeta ??= /* @__PURE__ */ new Map()).set(id, { createdAt: Date.now() });
2908
+ page.on("close", () => {
2909
+ if (!state.pages.has(id)) return;
2910
+ state.pages.delete(id);
2911
+ state.tabMeta?.delete(id);
2912
+ if (state.activeId === id) state.activeId = null;
2913
+ });
2789
2914
  return id;
2790
2915
  }
2791
2916
  /** 新建一个页面(当前 profile 会话未启动时先启动)。 */
@@ -2811,34 +2936,42 @@ async function newPage(config) {
2811
2936
  async function getPage(config) {
2812
2937
  const state = stateOf();
2813
2938
  if (state.browser === null && state.context === null) await launchProfile(currentProfile, config);
2939
+ pruneClosedPages(state);
2814
2940
  if (state.pages.size === 0) {
2815
2941
  const page = await newPage(config);
2816
- const id = state.counter++;
2817
- state.pages.set(id, page);
2818
- state.activeId = id;
2942
+ registerTabPage(state, page);
2943
+ return page;
2819
2944
  }
2820
2945
  const page = state.pages.get(state.activeId ?? -1);
2821
- if (page === void 0) throw new Error("dsh-browser: no active page");
2946
+ if (page === void 0) {
2947
+ const err = /* @__PURE__ */ new Error("dsh-browser: 活动标签已关闭或崩溃;请 browser_tabs { action: \"list\" } 查看存活标签并 switch,或 browser_open 打开目标 URL(将自动新建标签)");
2948
+ err.code = "activeTabClosed";
2949
+ throw err;
2950
+ }
2822
2951
  return page;
2823
2952
  }
2824
2953
  /** 活动页面(可能为 null,不触发启动)。 */
2825
2954
  function activePage() {
2826
2955
  const state = stateOf();
2956
+ pruneClosedPages(state);
2827
2957
  return state.pages.get(state.activeId ?? -1) ?? null;
2828
2958
  }
2829
- /** 投影当前 profile 的标签列表。 */
2959
+ /** 投影当前 profile 的标签列表(先摘除僵尸页,附注册时间)。 */
2830
2960
  async function tabList() {
2831
2961
  const state = stateOf();
2962
+ pruneClosedPages(state);
2832
2963
  const tabs = [];
2833
2964
  for (const [id, page] of state.pages) {
2834
2965
  let title = "";
2835
2966
  try {
2836
2967
  title = await page.title();
2837
2968
  } catch {}
2969
+ const createdAt = state.tabMeta?.get(id)?.createdAt;
2838
2970
  tabs.push({
2839
2971
  id,
2840
2972
  url: page.url(),
2841
- title
2973
+ title,
2974
+ ...createdAt !== void 0 ? { createdAt } : {}
2842
2975
  });
2843
2976
  }
2844
2977
  return tabs;
@@ -2851,6 +2984,7 @@ async function closeBrowser() {
2851
2984
  state.browser = null;
2852
2985
  state.context = null;
2853
2986
  state.pages = /* @__PURE__ */ new Map();
2987
+ state.tabMeta?.clear();
2854
2988
  state.activeId = null;
2855
2989
  state.counter = 0;
2856
2990
  try {
@@ -2893,15 +3027,38 @@ async function openTool(configOrArgs, maybeArgs) {
2893
3027
  const isSingleArg = maybeArgs === void 0;
2894
3028
  const config = isSingleArg && typeof configOrArgs?.url === "string" ? {} : configOrArgs;
2895
3029
  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
3030
+ const state = stateOf();
3031
+ let recovered;
3032
+ const freshPage = async () => {
3033
+ const p = await newPage(config ?? {});
3034
+ registerTabPage(state, p);
3035
+ recovered = "new-tab";
3036
+ return p;
3037
+ };
3038
+ let page = await getPage(config ?? {}).catch(async (e) => {
3039
+ if (e?.code === "activeTabClosed") return freshPage();
3040
+ throw e;
2900
3041
  });
3042
+ if (page.isClosed()) page = await freshPage();
3043
+ try {
3044
+ await page.goto(String(args.url), {
3045
+ waitUntil: args.waitUntil ?? "domcontentloaded",
3046
+ timeout: args.timeoutMs ?? config?.timeoutMs
3047
+ });
3048
+ } catch (e) {
3049
+ if (recovered === void 0 && /has been closed/.test(String(e?.message ?? e))) {
3050
+ page = await freshPage();
3051
+ await page.goto(String(args.url), {
3052
+ waitUntil: args.waitUntil ?? "domcontentloaded",
3053
+ timeout: args.timeoutMs ?? config?.timeoutMs
3054
+ });
3055
+ } else throw e;
3056
+ }
2901
3057
  return {
2902
3058
  url: page.url(),
2903
3059
  title: await page.title(),
2904
- status: "ok"
3060
+ status: "ok",
3061
+ ...recovered !== void 0 ? { recovered } : {}
2905
3062
  };
2906
3063
  }
2907
3064
  async function backTool(config) {
@@ -3057,9 +3214,7 @@ async function tabsTool(config, args) {
3057
3214
  };
3058
3215
  case "new": {
3059
3216
  const page = await newPage(config);
3060
- const id = state.counter++;
3061
- state.pages.set(id, page);
3062
- state.activeId = id;
3217
+ const id = registerTabPage(state, page);
3063
3218
  if (args.url) await page.goto(String(args.url), {
3064
3219
  waitUntil: args.waitUntil ?? "domcontentloaded",
3065
3220
  timeout: args.timeoutMs ?? config.timeoutMs
@@ -3089,12 +3244,10 @@ async function tabsTool(config, args) {
3089
3244
  const id = resolveTabId(args);
3090
3245
  const page = state.pages.get(id);
3091
3246
  if (page === void 0) throw new Error(`no tab with id ${id}`);
3092
- await page.close().catch(() => {});
3093
3247
  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
- }
3248
+ state.tabMeta?.delete(id);
3249
+ if (state.activeId === id) state.activeId = null;
3250
+ await page.close().catch(() => {});
3098
3251
  return {
3099
3252
  action,
3100
3253
  activeId: state.activeId ?? -1,
@@ -3609,7 +3762,7 @@ const removeSomOverlay = () => {
3609
3762
  document.getElementById("__dsh_som_overlay__")?.remove();
3610
3763
  };
3611
3764
  const injectSomOverlay = injectWalker((payload, w) => {
3612
- const { INTERACTIVE, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren } = w;
3765
+ const { INTERACTIVE, roleOf, nameOf, cssPath, isVisible, isElementInViewport, deepChildren, isProbablyClickable } = w;
3613
3766
  const marks = [];
3614
3767
  const snapScroll = {
3615
3768
  x: window.scrollX,
@@ -3625,6 +3778,7 @@ const injectSomOverlay = injectWalker((payload, w) => {
3625
3778
  candidates.push(el);
3626
3779
  return;
3627
3780
  }
3781
+ if (isVisible(el) && isElementInViewport(el, 0) && isProbablyClickable(el)) candidates.push(el);
3628
3782
  for (const c of deepChildren(el)) if (c.tagName !== "SCRIPT" && c.tagName !== "STYLE") collect(c);
3629
3783
  }
3630
3784
  if (document.body) collect(document.body);
@@ -3635,8 +3789,9 @@ const injectSomOverlay = injectWalker((payload, w) => {
3635
3789
  const rect = el.getBoundingClientRect();
3636
3790
  if (rect.width < 8 || rect.height < 8) continue;
3637
3791
  count += 1;
3638
- const role = roleOf(el);
3639
- const name = nameOf(el, role);
3792
+ const realRole = roleOf(el);
3793
+ const role = realRole === "" ? "clickable" : realRole;
3794
+ const name = realRole === "" ? (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60) : nameOf(el, realRole);
3640
3795
  const x = Math.max(0, rect.left);
3641
3796
  const y = Math.max(0, rect.top);
3642
3797
  const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
@@ -3953,6 +4108,22 @@ async function humanType(page, text, options = {}) {
3953
4108
  }
3954
4109
  //#endregion
3955
4110
  //#region lib/types/browser/tools/interaction.js
4111
+ /** 多命中可见化:解析层 matchedCount/visibleCount 透传到成功返回,>1 附操作指引。
4112
+ * 防同名字段静默打进被遮罩的背景元素(背景表单 + Modal 同名字段场景)。 */
4113
+ function ambiguityFields(res) {
4114
+ const matched = res.matchedCount;
4115
+ if (matched === void 0) return {};
4116
+ return {
4117
+ matchedCount: matched,
4118
+ ...res.visibleCount !== void 0 ? { visibleCount: res.visibleCount } : {},
4119
+ ...matched > 1 ? { hint: `命中 ${matched} 个元素(可见 ${res.visibleCount ?? 0}),已操作第 1 个;建议用 scope 限定或 browser_snapshot 取 ref 消歧` } : {}
4120
+ };
4121
+ }
4122
+ /** strict 模式(对齐 Playwright strict):多命中直接拒绝,要求定位唯一。 */
4123
+ function strictReject(args, res) {
4124
+ if (args.strict !== true || (res.matchedCount ?? 0) <= 1) return null;
4125
+ return /* @__PURE__ */ new Error(`strict 模式拒绝多命中: 命中 ${res.matchedCount} 个元素(可见 ${res.visibleCount ?? 0}),要求定位唯一;请用 scope 限定、更特异的 selector 或 browser_snapshot 取 ref`);
4126
+ }
3956
4127
  function isBotSensitiveUrl(url) {
3957
4128
  if (!url) return false;
3958
4129
  return /login|signin|auth|captcha|challenge|turnstile|security|waf/i.test(url);
@@ -3982,6 +4153,8 @@ async function clickTool(config, args, exec) {
3982
4153
  }
3983
4154
  let target = targetRes.locator;
3984
4155
  const label = String(args.ref ?? args.selector ?? args.mark ?? args.what ?? "");
4156
+ const strictErr = strictReject(args, targetRes);
4157
+ if (strictErr !== null) throw strictErr;
3985
4158
  target = await liftTargetIfHiddenInput(target);
3986
4159
  const wantEffect = args.effect === true;
3987
4160
  let urlBefore = "";
@@ -4074,17 +4247,24 @@ async function clickTool(config, args, exec) {
4074
4247
  clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
4075
4248
  effect,
4076
4249
  ...quiet ? { effectHint: "点击后未观察到任何 DOM/URL/网络变化,可能未生效(被遮挡/事件被吞/前置条件未满足);建议 browser_snapshot 核实或换目标" } : {},
4250
+ ...ambiguityFields(targetRes),
4251
+ ...targetRes.viaBridge === true ? { viaBridge: true } : {},
4077
4252
  ...await pageIdentity(page)
4078
4253
  };
4079
4254
  }
4080
4255
  return {
4081
4256
  clicked: String(args.ref ?? args.selector ?? args.mark ?? args.what ?? (args.row ? `row: ${args.row}` : "target")),
4257
+ ...ambiguityFields(targetRes),
4258
+ ...targetRes.viaBridge === true ? { viaBridge: true } : {},
4082
4259
  ...await pageIdentity(page)
4083
4260
  };
4084
4261
  }
4085
4262
  async function typeTool(config, args) {
4086
4263
  const page = await getPage(config);
4087
- const target = await targetLocator(page, args);
4264
+ const targetRes = await resolveTargetLocator(page, args);
4265
+ const target = targetRes.locator;
4266
+ const strictErr = strictReject(args, targetRes);
4267
+ if (strictErr !== null) throw strictErr;
4088
4268
  const text = String(args.text ?? "");
4089
4269
  const isHuman = args.humanize === true || args.humanize === void 0 && (config.humanize === true || config.humanize === "auto" && isBotSensitiveUrl(page.url()));
4090
4270
  if (args.editor === true) {
@@ -4093,6 +4273,7 @@ async function typeTool(config, args) {
4093
4273
  typed: true,
4094
4274
  editor: true,
4095
4275
  selector,
4276
+ ...ambiguityFields(targetRes),
4096
4277
  ...await pageIdentity(page)
4097
4278
  };
4098
4279
  }
@@ -4102,9 +4283,11 @@ async function typeTool(config, args) {
4102
4283
  await humanType(page, text, { speed: config.humanizeSpeed });
4103
4284
  } else await target.fill(text, { timeout: args.timeoutMs ?? config.timeoutMs });
4104
4285
  } else {
4286
+ const ambiguous = args.strict === true || (targetRes.matchedCount ?? 0) > 1;
4105
4287
  try {
4106
4288
  await target.click({ timeout: Math.min(args.timeoutMs ?? config.timeoutMs, 5e3) });
4107
- } catch {
4289
+ } catch (e) {
4290
+ if (ambiguous) throw new Error(`type 定位目标存在歧义(命中 ${targetRes.matchedCount ?? "?"} 个元素)且首个不可交互,已中止;请用 scope 限定或 browser_snapshot 取 ref 消歧;原始错误: ${e instanceof Error ? e.message : String(e)}`);
4108
4291
  await target.click({
4109
4292
  timeout: 2e3,
4110
4293
  force: true
@@ -4118,6 +4301,7 @@ async function typeTool(config, args) {
4118
4301
  typed: true,
4119
4302
  selector: String(args.ref ?? args.selector),
4120
4303
  submit: args.submit === true,
4304
+ ...ambiguityFields(targetRes),
4121
4305
  ...await pageIdentity(page)
4122
4306
  };
4123
4307
  }
@@ -4144,8 +4328,18 @@ async function pressTool(config, args) {
4144
4328
  } else warning = "检测到页面存在活动弹窗 [modal: closeOnEsc],按全局 Escape 可能导致弹窗及其表单被关闭。若需关闭指定元素请传 selector/ref。";
4145
4329
  }
4146
4330
  }
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);
4331
+ if (args.selector !== void 0 || args.ref !== void 0 || typeof args.what === "string" && args.what.trim() !== "") {
4332
+ const targetRes = await resolveTargetLocator(page, args);
4333
+ const strictErr = strictReject(args, targetRes);
4334
+ if (strictErr !== null) throw strictErr;
4335
+ await targetRes.locator.press(key, { timeout: args.timeoutMs ?? config.timeoutMs });
4336
+ return {
4337
+ pressed: key,
4338
+ ...warning ? { warning } : {},
4339
+ ...ambiguityFields(targetRes),
4340
+ ...await pageIdentity(page)
4341
+ };
4342
+ } else await page.keyboard.press(key);
4149
4343
  return {
4150
4344
  pressed: key,
4151
4345
  ...warning ? { warning } : {},
@@ -4158,8 +4352,11 @@ async function pressTool(config, args) {
4158
4352
  */
4159
4353
  async function selectTool(config, args) {
4160
4354
  const page = await getPage(config);
4161
- const target = await targetLocator(page, args);
4162
- const label = String(args.ref ?? args.selector);
4355
+ const targetRes = await resolveTargetLocator(page, args);
4356
+ const target = targetRes.locator;
4357
+ const strictErr = strictReject(args, targetRes);
4358
+ if (strictErr !== null) throw strictErr;
4359
+ const label = String(args.ref ?? args.selector ?? args.what ?? "");
4163
4360
  const want = String(args.option ?? "");
4164
4361
  if (!want) throw new Error("select 需要 option(选项文本)");
4165
4362
  return {
@@ -4173,6 +4370,7 @@ async function selectTool(config, args) {
4173
4370
  clear: args.clear === true
4174
4371
  })).selected,
4175
4372
  selector: label,
4373
+ ...ambiguityFields(targetRes),
4176
4374
  ...await pageIdentity(page)
4177
4375
  };
4178
4376
  }
@@ -4482,7 +4680,53 @@ async function checkOnce(page, cond) {
4482
4680
  detail: `eval 执行失败: ${e instanceof Error ? e.message : String(e)}`
4483
4681
  };
4484
4682
  }
4485
- throw new Error("条件无效: 需要 selector/url/text/count/eval 之一");
4683
+ if (cond.bridge !== void 0 && typeof cond.bridge === "object") {
4684
+ const spec = cond.bridge;
4685
+ const method = String(spec.method ?? "");
4686
+ const trunc = (v) => {
4687
+ const s = JSON.stringify(v) ?? String(v);
4688
+ return s.length > 500 ? `${s.slice(0, 500)}…(截断)` : s;
4689
+ };
4690
+ if (method === "") return {
4691
+ ok: false,
4692
+ detail: "bridge 条件需要 method(__DSH_BRIDGE__ 方法名)"
4693
+ };
4694
+ try {
4695
+ const value = await page.evaluate(async (payload) => {
4696
+ const bridge = window.__DSH_BRIDGE__;
4697
+ if (bridge === void 0 || bridge === null) throw new Error("NO_BRIDGE:页面无 __DSH_BRIDGE__(未接入 vite-plugin-dsh-bridge?)");
4698
+ if (typeof bridge[payload.method] !== "function") throw new Error(`NO_BRIDGE:__DSH_BRIDGE__ 无方法 ${payload.method}`);
4699
+ return await bridge[payload.method](...payload.args);
4700
+ }, {
4701
+ method,
4702
+ args: Array.isArray(spec.args) ? spec.args : []
4703
+ });
4704
+ const actual = JSON.parse(JSON.stringify(value ?? null));
4705
+ if (spec.expectContains !== void 0) return (typeof actual === "string" ? actual : JSON.stringify(actual)).includes(String(spec.expectContains)) ? { ok: true } : {
4706
+ ok: false,
4707
+ detail: `[bridge] ${method}() 结果不含 "${spec.expectContains}"(实际: ${trunc(actual)})`
4708
+ };
4709
+ if (spec.expect !== void 0) return JSON.stringify(actual) === JSON.stringify(spec.expect) ? { ok: true } : {
4710
+ ok: false,
4711
+ detail: `[bridge] ${method}() = ${trunc(actual)} ≠ 期望 ${trunc(spec.expect)}`
4712
+ };
4713
+ return actual ? { ok: true } : {
4714
+ ok: false,
4715
+ detail: `[bridge] ${method}() 返回假值: ${trunc(actual)}`
4716
+ };
4717
+ } catch (e) {
4718
+ const msg = e instanceof Error ? e.message : String(e);
4719
+ if (msg.startsWith("NO_BRIDGE:")) return {
4720
+ ok: false,
4721
+ detail: `[bridge] ${msg.slice(10)}`
4722
+ };
4723
+ return {
4724
+ ok: false,
4725
+ detail: `[bridge] ${method}() 执行失败: ${msg}`
4726
+ };
4727
+ }
4728
+ }
4729
+ throw new Error("条件无效: 需要 selector/url/text/count/eval/bridge 之一");
4486
4730
  }
4487
4731
  /** 轮询等待条件成立(intervalMs 步进),返回最后一次检查结果。 */
4488
4732
  async function pollCondition(page, cond, timeoutMs, intervalMs = 500) {
@@ -4668,12 +4912,28 @@ async function visualAssert(config, visual, page, exec) {
4668
4912
  };
4669
4913
  }
4670
4914
  function matchNetwork(entry, f) {
4671
- if (f.urlSubstr && !entry.url.includes(String(f.urlSubstr))) return false;
4915
+ if (f.urlSubstr && !entry.url.toLowerCase().includes(String(f.urlSubstr).toLowerCase())) return false;
4672
4916
  if (f.method && entry.method.toUpperCase() !== String(f.method).toUpperCase()) return false;
4673
4917
  if (f.status !== void 0 && Number(f.status) !== entry.status) return false;
4674
4918
  if (f.failedOnly === true && !entry.failed && (entry.status === void 0 || entry.status < 400)) return false;
4675
4919
  return true;
4676
4920
  }
4921
+ /** urlSubstr 零命中时的相近 URL 候选(top5,去重,LCS 打分),与 what 未命中的
4922
+ * 候选体验对齐——把"子串写错"与"接口没调"区分开。 */
4923
+ function urlSuggestions(entries, substr) {
4924
+ const min = substr.trim().length <= 3 ? 1 : 2;
4925
+ const seen = /* @__PURE__ */ new Set();
4926
+ const scored = [];
4927
+ for (const e of entries) {
4928
+ if (seen.has(e.url)) continue;
4929
+ seen.add(e.url);
4930
+ scored.push({
4931
+ url: e.url,
4932
+ score: lcsScore(substr, e.url)
4933
+ });
4934
+ }
4935
+ return scored.filter((s) => s.score >= min).sort((a, b) => b.score - a.score).slice(0, 5).map((s) => s.url);
4936
+ }
4677
4937
  /** 网络记录查询:list / failed / wait / clear。兼容 (args) 与 (config, args) 两种调用签名。 */
4678
4938
  async function networkTool(configOrArgs, maybeArgs) {
4679
4939
  const args = (maybeArgs !== void 0 ? maybeArgs : configOrArgs) ?? {};
@@ -4748,8 +5008,20 @@ async function networkTool(configOrArgs, maybeArgs) {
4748
5008
  byStatus: b.byStatus
4749
5009
  })).sort((a, b) => b.total - a.total) };
4750
5010
  }
4751
- if (action === "failed") return { failed: list };
4752
- if (action === "list") return { entries: list };
5011
+ if (action === "failed") {
5012
+ const suggestions = list.length === 0 && args.urlSubstr ? urlSuggestions(entries, String(args.urlSubstr)) : [];
5013
+ return suggestions.length > 0 ? {
5014
+ failed: list,
5015
+ suggestions
5016
+ } : { failed: list };
5017
+ }
5018
+ if (action === "list") {
5019
+ const suggestions = list.length === 0 && args.urlSubstr ? urlSuggestions(entries, String(args.urlSubstr)) : [];
5020
+ return suggestions.length > 0 ? {
5021
+ entries: list,
5022
+ suggestions
5023
+ } : { entries: list };
5024
+ }
4753
5025
  throw new Error(`unknown network action: ${action}`);
4754
5026
  }
4755
5027
  //#endregion
@@ -5996,7 +6268,7 @@ function compileToPlaywrightSpec(steps, options = {}) {
5996
6268
  else if (args.selector) {
5997
6269
  const state = args.state || "visible";
5998
6270
  lines.push(` await page.locator(${JSON.stringify(args.selector)}).waitFor({ state: ${JSON.stringify(state)} });`);
5999
- }
6271
+ } else if (args.condition?.bridge !== void 0 || args.bridge !== void 0) lines.push(` // bridge 条件请在接入 vite-plugin-dsh-bridge 的环境手动断言: await __DSH_BRIDGE__.<method>(...)`);
6000
6272
  break;
6001
6273
  case "browser_assert": {
6002
6274
  const sel = args.selector || "body";
@@ -6004,6 +6276,9 @@ function compileToPlaywrightSpec(steps, options = {}) {
6004
6276
  else if (args.visible !== void 0) {
6005
6277
  if (args.visible) lines.push(` await expect(page.locator(${JSON.stringify(sel)})).toBeVisible();`);
6006
6278
  else lines.push(` await expect(page.locator(${JSON.stringify(sel)})).toBeHidden();`);
6279
+ } else if (args.condition?.bridge !== void 0 || args.bridge !== void 0) {
6280
+ const bridge = args.condition?.bridge ?? args.bridge;
6281
+ lines.push(` // bridge 断言请在接入 vite-plugin-dsh-bridge 的环境手动执行: const v = await __DSH_BRIDGE__.${String(bridge?.method ?? "method")}(); expect(v).toBeTruthy();`);
6007
6282
  }
6008
6283
  break;
6009
6284
  }
@@ -6658,7 +6933,7 @@ function getNavigationTools(config, timeout, stepImpls) {
6658
6933
  return [
6659
6934
  definePlainTool({
6660
6935
  name: "browser_open",
6661
- description: "在共享浏览器中打开一个 URL。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
6936
+ description: "在共享浏览器中打开一个 URL。活动标签已关闭或崩溃时自动新建标签重试(返回标 recovered: 'new-tab')。之后可用 browser_snapshot 读取页面内容,用 browser_click / browser_type / browser_eval 操作页面,用 browser_screenshot 截图。",
6662
6937
  parameters: {
6663
6938
  type: "object",
6664
6939
  properties: {
@@ -6689,7 +6964,11 @@ function getNavigationTools(config, timeout, stepImpls) {
6689
6964
  properties: {
6690
6965
  url: { type: "string" },
6691
6966
  title: { type: "string" },
6692
- status: { type: "string" }
6967
+ status: { type: "string" },
6968
+ recovered: {
6969
+ type: "string",
6970
+ description: "活动标签已死自动换新页时为 'new-tab'。"
6971
+ }
6693
6972
  },
6694
6973
  required: [
6695
6974
  "url",
@@ -6798,7 +7077,7 @@ function getNavigationTools(config, timeout, stepImpls) {
6798
7077
  }),
6799
7078
  definePlainTool({
6800
7079
  name: "browser_tabs",
6801
- description: "管理浏览器多标签页:list 列出所有标签,new 新建(可选带 url),switch 按 id 或 index 切换,close 关闭指定标签。id 来自 list 结果。",
7080
+ description: "管理浏览器多标签页:list 列出所有标签(每项含 createdAt 注册时间;已关闭的僵尸页自动摘除),new 新建(可选带 url),switch 按 id 或 index 切换,close 关闭指定标签。id 来自 list 结果。",
6802
7081
  parameters: {
6803
7082
  type: "object",
6804
7083
  properties: {
@@ -6975,21 +7254,21 @@ function getInteractionTools(config, timeout, stepImpls) {
6975
7254
  return [
6976
7255
  definePlainTool({
6977
7256
  name: "browser_click",
6978
- description: "点击页面元素:selector(CSS)、ref(来自 browser_snapshot mode:'aria')、what(意图描述,如 '登录'/'提交订单',页内打分匹配并自动注册 ref)或 mark(SoM 标徽编号,看图定位)四选一。也可通过 row(如 'TASK-1002') 与 action(如 '编辑') 直接定位表格中某行操作按钮。what 零命中时报错并附最相近候选与 SoM 标徽截图。",
7257
+ description: "点击页面元素:selector(CSS)、ref(来自 browser_snapshot mode:'aria')、what(意图描述,如 '登录'/'提交订单',页内打分匹配并自动注册 ref)或 mark(SoM 标徽编号,看图定位)四选一——同时传多个会直接报参数错误;容器内语义定位用 what + scope。也可通过 row(如 'TASK-1002') 与 action(如 '编辑') 直接定位表格中某行操作按钮(row 可组合 selector 限定行内目标)。what 零命中时报错并附最相近候选与 SoM 标徽截图。",
6979
7258
  parameters: {
6980
7259
  type: "object",
6981
7260
  properties: {
6982
7261
  selector: {
6983
7262
  type: "string",
6984
- description: "CSS 选择器。"
7263
+ description: "CSS 选择器(与 ref/mark/what 互斥)。"
6985
7264
  },
6986
7265
  ref: {
6987
7266
  type: "string",
6988
- description: "aria 快照的 ref(如 'e3');导航后失效。"
7267
+ description: "aria 快照的 ref(如 'e3');导航后失效。与 selector/mark/what 互斥。"
6989
7268
  },
6990
7269
  what: {
6991
7270
  type: "string",
6992
- description: "意图描述(元素名称/文本,如 '登录'):页内打分语义定位,免快照直达;零命中返回最相近候选。"
7271
+ description: "意图描述(元素名称/文本,如 '登录'):页内打分语义定位,免快照直达;零命中返回最相近候选。与 selector/ref/mark 互斥;容器内定位配合 scope。"
6993
7272
  },
6994
7273
  scope: {
6995
7274
  type: "string",
@@ -6997,11 +7276,11 @@ function getInteractionTools(config, timeout, stepImpls) {
6997
7276
  },
6998
7277
  mark: {
6999
7278
  type: "integer",
7000
- description: "SoM 标徽编号(browser_screenshot { som: true } 返回 somMarks 的 id);多模态看图定位的落点,导航后失效。"
7279
+ description: "SoM 标徽编号(browser_screenshot { som: true } 返回 somMarks 的 id);多模态看图定位的落点,导航后失效。与 selector/ref/what 互斥。"
7001
7280
  },
7002
7281
  row: {
7003
7282
  type: "string",
7004
- description: "表格行文本标识,如 'TASK-1002',用于行级定向操作。"
7283
+ description: "表格行文本标识,如 'TASK-1002',用于行级定向操作(可组合 selector 限定行内目标,不与 what/ref/mark 同用)。"
7005
7284
  },
7006
7285
  action: {
7007
7286
  type: "string",
@@ -7019,6 +7298,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7019
7298
  type: "boolean",
7020
7299
  description: "点击效果验证:收集点击后的 DOM 变化摘要/URL 变化/新增网络请求;what 多候选首击无动静时自动换次候选。"
7021
7300
  },
7301
+ strict: {
7302
+ type: "boolean",
7303
+ description: "多命中即抛错(对齐 Playwright strict),要求定位唯一;默认 false 操作第 1 个并在返回中提示命中数。"
7304
+ },
7022
7305
  timeoutMs: { type: "integer" }
7023
7306
  },
7024
7307
  additionalProperties: false
@@ -7031,7 +7314,23 @@ function getInteractionTools(config, timeout, stepImpls) {
7031
7314
  url: { type: "string" },
7032
7315
  title: { type: "string" },
7033
7316
  effect: { type: "object" },
7034
- effectHint: { type: "string" }
7317
+ effectHint: { type: "string" },
7318
+ matchedCount: {
7319
+ type: "integer",
7320
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7321
+ },
7322
+ visibleCount: {
7323
+ type: "integer",
7324
+ description: "命中元素中的可见数量。"
7325
+ },
7326
+ hint: {
7327
+ type: "string",
7328
+ description: "matchedCount>1 时的消歧指引。"
7329
+ },
7330
+ viaBridge: {
7331
+ type: "boolean",
7332
+ description: "what 未命中时经页面 __DSH_BRIDGE__(vite-plugin-dsh-bridge)兜底命中。"
7333
+ }
7035
7334
  },
7036
7335
  required: [
7037
7336
  "clicked",
@@ -7045,18 +7344,21 @@ function getInteractionTools(config, timeout, stepImpls) {
7045
7344
  }),
7046
7345
  definePlainTool({
7047
7346
  name: "browser_type",
7048
- description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')定位目标。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。",
7347
+ description: "向输入框输入文本:selector(CSS)、ref 或 what(意图描述,如 '用户名'/'搜索')三选一定位目标(同时传多个会报参数错误;容器内定位用 what + scope)。clear=true 时先清空再输入(推荐用于表单);submit=true 时输入后按回车;editor=true 时适配 Monaco/CodeMirror/富文本编辑器。",
7049
7348
  parameters: {
7050
7349
  type: "object",
7051
7350
  properties: {
7052
- selector: { type: "string" },
7351
+ selector: {
7352
+ type: "string",
7353
+ description: "CSS 选择器(与 ref/what 互斥)。"
7354
+ },
7053
7355
  ref: {
7054
7356
  type: "string",
7055
- description: "aria 快照的 ref(如 'e2')。"
7357
+ description: "aria 快照的 ref(如 'e2')。与 selector/what 互斥。"
7056
7358
  },
7057
7359
  what: {
7058
7360
  type: "string",
7059
- description: "意图描述(元素名称/label/placeholder,如 '用户名'):页内打分语义定位。"
7361
+ description: "意图描述(元素名称/label/placeholder,如 '用户名'):页内打分语义定位。与 selector/ref 互斥;容器内定位配合 scope。"
7060
7362
  },
7061
7363
  scope: {
7062
7364
  type: "string",
@@ -7073,6 +7375,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7073
7375
  type: "boolean",
7074
7376
  description: "使用高斯间隔拟人化逐字输入。"
7075
7377
  },
7378
+ strict: {
7379
+ type: "boolean",
7380
+ description: "多命中即抛错(对齐 Playwright strict);多命中或 strict 时不再 force 降级点击,遮罩拦截会直接暴露。"
7381
+ },
7076
7382
  delayMs: { type: "integer" },
7077
7383
  timeoutMs: { type: "integer" }
7078
7384
  },
@@ -7087,7 +7393,19 @@ function getInteractionTools(config, timeout, stepImpls) {
7087
7393
  selector: { type: "string" },
7088
7394
  submit: { type: "boolean" },
7089
7395
  url: { type: "string" },
7090
- title: { type: "string" }
7396
+ title: { type: "string" },
7397
+ matchedCount: {
7398
+ type: "integer",
7399
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7400
+ },
7401
+ visibleCount: {
7402
+ type: "integer",
7403
+ description: "命中元素中的可见数量。"
7404
+ },
7405
+ hint: {
7406
+ type: "string",
7407
+ description: "matchedCount>1 时的消歧指引。"
7408
+ }
7091
7409
  },
7092
7410
  required: [
7093
7411
  "typed",
@@ -7103,7 +7421,7 @@ function getInteractionTools(config, timeout, stepImpls) {
7103
7421
  }),
7104
7422
  definePlainTool({
7105
7423
  name: "browser_press",
7106
- description: "按键:有 selector 或 ref 时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
7424
+ description: "按键:有 selector、ref 或 what(三选一,互斥)时先聚焦该元素再按键(如 'Enter'、'Tab'、'Escape'、'Control+a'),否则在页面级按键。",
7107
7425
  parameters: {
7108
7426
  type: "object",
7109
7427
  properties: {
@@ -7111,14 +7429,21 @@ function getInteractionTools(config, timeout, stepImpls) {
7111
7429
  type: "string",
7112
7430
  description: "按键名(Playwright 键盘键名)。"
7113
7431
  },
7114
- selector: { type: "string" },
7432
+ selector: {
7433
+ type: "string",
7434
+ description: "CSS 选择器(与 ref/what 互斥)。"
7435
+ },
7115
7436
  ref: {
7116
7437
  type: "string",
7117
- description: "aria 快照的 ref。"
7438
+ description: "aria 快照的 ref。与 selector/what 互斥。"
7118
7439
  },
7119
7440
  what: {
7120
7441
  type: "string",
7121
- description: "意图描述:先聚焦该元素再按键(页内打分语义定位)。"
7442
+ description: "意图描述:先聚焦该元素再按键(页内打分语义定位)。与 selector/ref 互斥。"
7443
+ },
7444
+ strict: {
7445
+ type: "boolean",
7446
+ description: "多命中即抛错(对齐 Playwright strict)。"
7122
7447
  },
7123
7448
  timeoutMs: { type: "integer" }
7124
7449
  },
@@ -7131,7 +7456,20 @@ function getInteractionTools(config, timeout, stepImpls) {
7131
7456
  properties: {
7132
7457
  pressed: { type: "string" },
7133
7458
  url: { type: "string" },
7134
- title: { type: "string" }
7459
+ title: { type: "string" },
7460
+ warning: { type: "string" },
7461
+ matchedCount: {
7462
+ type: "integer",
7463
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7464
+ },
7465
+ visibleCount: {
7466
+ type: "integer",
7467
+ description: "命中元素中的可见数量。"
7468
+ },
7469
+ hint: {
7470
+ type: "string",
7471
+ description: "matchedCount>1 时的消歧指引。"
7472
+ }
7135
7473
  },
7136
7474
  required: [
7137
7475
  "pressed",
@@ -7145,21 +7483,25 @@ function getInteractionTools(config, timeout, stepImpls) {
7145
7483
  }),
7146
7484
  definePlainTool({
7147
7485
  name: "browser_select",
7148
- description: "下拉选择原语:原生 <select> 与组件库下拉(antd / element-plus / ARIA listbox)通用。selector 或 ref 指向下拉触发框,option 为选项文本(默认包含匹配,exact=true 精确)。多选下拉传 clear:true 可在选择前先清空旧标签实现替换语义。找不到选项时报错并列出可见选项,无需再 browser_eval 探查。",
7486
+ description: "下拉选择原语:原生 <select> 与组件库下拉(antd / element-plus / ARIA listbox)通用。selector、ref 或 what(三选一,互斥)指向下拉触发框,option 为选项文本(默认包含匹配,exact=true 精确)。多选下拉传 clear:true 可在选择前先清空旧标签实现替换语义。找不到选项时报错并列出可见选项,无需再 browser_eval 探查。",
7149
7487
  parameters: {
7150
7488
  type: "object",
7151
7489
  properties: {
7152
7490
  selector: {
7153
7491
  type: "string",
7154
- description: "下拉触发框的 CSS 选择器(如 #rc_select_1)。"
7492
+ description: "下拉触发框的 CSS 选择器(如 #rc_select_1);与 ref/what 互斥。"
7155
7493
  },
7156
7494
  ref: {
7157
7495
  type: "string",
7158
- description: "aria 快照的 ref。"
7496
+ description: "aria 快照的 ref。与 selector/what 互斥。"
7159
7497
  },
7160
7498
  what: {
7161
7499
  type: "string",
7162
- description: "意图描述(下拉的 label/名称,如 '所属部门'):页内打分语义定位触发框。"
7500
+ description: "意图描述(下拉的 label/名称,如 '所属部门'):页内打分语义定位触发框。与 selector/ref 互斥;容器内定位配合 scope。"
7501
+ },
7502
+ scope: {
7503
+ type: "string",
7504
+ description: "配合 what 限定扫描范围的容器选择器。"
7163
7505
  },
7164
7506
  option: {
7165
7507
  type: "string",
@@ -7173,6 +7515,10 @@ function getInteractionTools(config, timeout, stepImpls) {
7173
7515
  type: "boolean",
7174
7516
  description: "是否在选择前先清空已有选中项(对于多选下拉,替换而非叠加旧选项)。"
7175
7517
  },
7518
+ strict: {
7519
+ type: "boolean",
7520
+ description: "多命中即抛错(对齐 Playwright strict)。"
7521
+ },
7176
7522
  timeoutMs: { type: "integer" }
7177
7523
  },
7178
7524
  required: ["option"],
@@ -7185,7 +7531,19 @@ function getInteractionTools(config, timeout, stepImpls) {
7185
7531
  selected: { type: "string" },
7186
7532
  selector: { type: "string" },
7187
7533
  url: { type: "string" },
7188
- title: { type: "string" }
7534
+ title: { type: "string" },
7535
+ matchedCount: {
7536
+ type: "integer",
7537
+ description: "选择器命中的元素总数(多命中歧义可见化)。"
7538
+ },
7539
+ visibleCount: {
7540
+ type: "integer",
7541
+ description: "命中元素中的可见数量。"
7542
+ },
7543
+ hint: {
7544
+ type: "string",
7545
+ description: "matchedCount>1 时的消歧指引。"
7546
+ }
7189
7547
  },
7190
7548
  required: [
7191
7549
  "selected",
@@ -7841,7 +8199,7 @@ function getTestingTools(config, timeout, stepImpls) {
7841
8199
  }),
7842
8200
  definePlainTool({
7843
8201
  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 语句)}。用于等页面渲染、等请求完成、等元素出现。",
8202
+ 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
8203
  parameters: {
7846
8204
  type: "object",
7847
8205
  properties: {
@@ -7881,13 +8239,13 @@ function getTestingTools(config, timeout, stepImpls) {
7881
8239
  }),
7882
8240
  definePlainTool({
7883
8241
  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 图。适合断言登录成功、关键元素出现、接口返回、文本正确、页面无视觉回归。",
8242
+ 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
8243
  parameters: {
7886
8244
  type: "object",
7887
8245
  properties: {
7888
8246
  condition: {
7889
8247
  type: "object",
7890
- description: "断言条件(同 browser_wait_for,另支持 {ref, state})。"
8248
+ description: "断言条件(同 browser_wait_for,另支持 {ref, state} 与 {bridge:{method,args?,expect?}})。"
7891
8249
  },
7892
8250
  visual: {
7893
8251
  type: "object",
@@ -7942,7 +8300,7 @@ function getTestingTools(config, timeout, stepImpls) {
7942
8300
  },
7943
8301
  urlSubstr: {
7944
8302
  type: "string",
7945
- description: "URL 子串过滤(list/failed)。"
8303
+ description: "URL 子串过滤(list/failed,大小写不敏感;零命中时附 suggestions 相近 URL 候选,防误判接口未调用)。"
7946
8304
  },
7947
8305
  method: {
7948
8306
  type: "string",
@@ -7985,7 +8343,12 @@ function getTestingTools(config, timeout, stepImpls) {
7985
8343
  },
7986
8344
  matched: { type: "object" },
7987
8345
  waitMs: { type: "integer" },
7988
- cleared: { type: "boolean" }
8346
+ cleared: { type: "boolean" },
8347
+ suggestions: {
8348
+ type: "array",
8349
+ items: { type: "string" },
8350
+ description: "urlSubstr 零命中时的 top5 相近 URL 候选。"
8351
+ }
7989
8352
  },
7990
8353
  required: []
7991
8354
  },
@@ -49,7 +49,9 @@ export interface NamedContext {
49
49
  createdAt: number;
50
50
  }
51
51
  export declare function namedContextsOf(): Map<string, NamedContext>;
52
- /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。 */
52
+ /** 把一个已 attach 的页面注册进 profile 标签表并激活,返回分配的 tab id。
53
+ * 唯一注册路径:同时挂 close 监听幂等摘除外部关闭的页(否则僵尸句柄会永久占用
54
+ * activeId,所有工具连报 "Target page...closed" 而 tabs list 仍显示存活)。 */
53
55
  export declare function registerTabPage(state: ProfileState, page: Page): number;
54
56
  /** 新建一个页面(当前 profile 会话未启动时先启动)。 */
55
57
  export declare function newPage(config: BrowserConfig): Promise<Page>;
@@ -57,8 +59,9 @@ export declare function newPage(config: BrowserConfig): Promise<Page>;
57
59
  export declare function getPage(config: BrowserConfig): Promise<Page>;
58
60
  /** 活动页面(可能为 null,不触发启动)。 */
59
61
  export declare function activePage(): Page | null;
60
- /** 投影当前 profile 的标签列表。 */
62
+ /** 投影当前 profile 的标签列表(先摘除僵尸页,附注册时间)。 */
61
63
  export declare function tabList(): Promise<{
64
+ createdAt?: number | undefined;
62
65
  id: number;
63
66
  url: string;
64
67
  title: string;
@@ -7,6 +7,7 @@ export declare function isBotSensitiveUrl(url: string): boolean;
7
7
  export declare function clickTool(config: BrowserConfig, args: Record<string, any>, exec?: ToolRunContext): Promise<{
8
8
  url: string;
9
9
  title: string;
10
+ viaBridge?: boolean | undefined;
10
11
  effectHint?: string | undefined;
11
12
  clicked: string;
12
13
  effect: {
@@ -23,6 +24,7 @@ export declare function clickTool(config: BrowserConfig, args: Record<string, an
23
24
  } | {
24
25
  url: string;
25
26
  title: string;
27
+ viaBridge?: boolean | undefined;
26
28
  clicked: string;
27
29
  }>;
28
30
  export declare function typeTool(config: BrowserConfig, args: Record<string, any>): Promise<{
@@ -1,5 +1,6 @@
1
1
  import type { BrowserConfig } from "../types.js";
2
2
  export declare function openTool(configOrArgs: BrowserConfig | Record<string, any>, maybeArgs?: Record<string, any>): Promise<{
3
+ recovered?: string | undefined;
3
4
  url: string;
4
5
  title: string;
5
6
  status: string;
@@ -90,6 +91,7 @@ export declare function statusTool(): Promise<{
90
91
  export declare function tabsTool(config: BrowserConfig, args: Record<string, any>): Promise<{
91
92
  action: any;
92
93
  tabs: {
94
+ createdAt?: number | undefined;
93
95
  id: number;
94
96
  url: string;
95
97
  title: string;
@@ -104,11 +106,12 @@ export declare function tabsTool(config: BrowserConfig, args: Record<string, any
104
106
  title: string;
105
107
  };
106
108
  tabs: {
109
+ createdAt?: number | undefined;
107
110
  id: number;
108
111
  url: string;
109
112
  title: string;
110
113
  }[];
111
- activeId: number;
114
+ activeId: number | null;
112
115
  }>;
113
116
  export declare function profileTool(config: BrowserConfig, args: Record<string, any>): Promise<{
114
117
  current: string;
@@ -9,12 +9,13 @@ export declare function waitTool(config: BrowserConfig, args: Record<string, any
9
9
  /**
10
10
  * 条件检查引擎(测试原语共用)。
11
11
  *
12
- * cond 支持四类(每类内部互斥,取第一个存在的字段):
12
+ * cond 支持六类(每类内部互斥,取第一个存在的字段):
13
13
  * { selector: "#btn", state: "visible|hidden|attached|detached" } 元素状态(默认 visible)
14
14
  * { url: "正则字符串" } 当前 URL 匹配
15
15
  * { text: "子串" } body 可见文本包含
16
16
  * { count: { selector, op: "eq|gt|gte|lt|lte", value: n } } 元素数量比较
17
17
  * { eval: "JS 表达式" } 表达式求值 truthy
18
+ * { bridge: { method, args?, expect?/expectContains? } } 页内调用 __DSH_BRIDGE__ 方法并断言返回
18
19
  */
19
20
  export type TestCondition = Record<string, any>;
20
21
  /** 单次检查:返回 { ok, detail }。不抛错,失败时 detail 给出原因。 */
@@ -56,6 +57,7 @@ export declare function networkTool(configOrArgs?: Record<string, any>, maybeArg
56
57
  waitMs?: undefined;
57
58
  stats?: undefined;
58
59
  failed?: undefined;
60
+ suggestions?: undefined;
59
61
  } | {
60
62
  matched: import("../session.js").NetworkEntry;
61
63
  waitMs: number;
@@ -63,6 +65,7 @@ export declare function networkTool(configOrArgs?: Record<string, any>, maybeArg
63
65
  entries?: undefined;
64
66
  stats?: undefined;
65
67
  failed?: undefined;
68
+ suggestions?: undefined;
66
69
  } | {
67
70
  stats: {
68
71
  host: string;
@@ -76,13 +79,31 @@ export declare function networkTool(configOrArgs?: Record<string, any>, maybeArg
76
79
  matched?: undefined;
77
80
  waitMs?: undefined;
78
81
  failed?: undefined;
82
+ suggestions?: undefined;
79
83
  } | {
80
84
  failed: import("../session.js").NetworkEntry[];
85
+ suggestions: string[];
81
86
  cleared?: undefined;
82
87
  entries?: undefined;
83
88
  matched?: undefined;
84
89
  waitMs?: undefined;
85
90
  stats?: undefined;
91
+ } | {
92
+ failed: import("../session.js").NetworkEntry[];
93
+ cleared?: undefined;
94
+ entries?: undefined;
95
+ matched?: undefined;
96
+ waitMs?: undefined;
97
+ stats?: undefined;
98
+ suggestions?: undefined;
99
+ } | {
100
+ entries: import("../session.js").NetworkEntry[];
101
+ suggestions: string[];
102
+ cleared?: undefined;
103
+ matched?: undefined;
104
+ waitMs?: undefined;
105
+ stats?: undefined;
106
+ failed?: undefined;
86
107
  } | {
87
108
  entries: import("../session.js").NetworkEntry[];
88
109
  cleared?: undefined;
@@ -90,5 +111,6 @@ export declare function networkTool(configOrArgs?: Record<string, any>, maybeArg
90
111
  waitMs?: undefined;
91
112
  stats?: undefined;
92
113
  failed?: undefined;
114
+ suggestions?: undefined;
93
115
  }>;
94
116
  //# sourceMappingURL=testing.d.ts.map
@@ -92,6 +92,10 @@ export interface ProfileState {
92
92
  activeId: number | null;
93
93
  counter: number;
94
94
  channel?: string;
95
+ /** 标签元数据(id → createdAt);随 pages 同步增删,僵尸标签治理用。 */
96
+ tabMeta?: Map<number, {
97
+ createdAt: number;
98
+ }>;
95
99
  }
96
100
  /** 一次页面下载的记录。 */
97
101
  export interface DownloadEntry {
@@ -173,6 +177,8 @@ export interface TargetResolution {
173
177
  visibleCount?: number;
174
178
  /** 语义定位的备选目标(按打分次序,最多 2 个);点击效果验证无变化时自动换选。 */
175
179
  alternates?: Locator[];
180
+ /** 经 window.__DSH_BRIDGE__.resolveActionTarget 兜底命中(vite-plugin-dsh-bridge 反哺)。 */
181
+ viaBridge?: boolean;
176
182
  }
177
183
  /** 录制时采集的选择器指纹(回放自愈的降级候选来源)。 */
178
184
  export interface SelectorHint {
@@ -20,6 +20,7 @@ declare function __walkerCore(): {
20
20
  };
21
21
  composedParent: (el: Element) => Element | null;
22
22
  deepChildren: (el: Element) => Element[];
23
+ isProbablyClickable: (el: Element) => boolean;
23
24
  };
24
25
  /** walker 核心注入后暴露给 entry 的 API(类型层)。 */
25
26
  export type WalkerApi = ReturnType<typeof __walkerCore>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-view",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "DSH 插件:共享 Playwright 浏览器自动化(38 个 browser_* 工具,含 what 意图语义定位(一次调用替代快照→查找→点击)、SoM 标徽看图点击与零命中截图兜底、SPA ref 软失效重锚定、open Shadow DOM 组合树穿透、跨 Frame/IFrame 定位、视口感知动态快照、批量表单智能填充、点击效果验证与定位失败遥测、Playwright 规范代码导出、批处理、视觉回归、选择器自愈、增量快照)+ 人机协作(面板点选教学 browser_pick_element、人工接管等待、敏感操作审批硬拦截、人机混合录制、面板一键重跑)+ CDP 流式推流、拟人化轨迹与高斯击键、全景时光机、企业富控件矩阵。仅依赖 playwright-core。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -34,6 +34,7 @@
34
34
  "test:enterprise": "tsx scripts/test-dev-server-enterprise.ts",
35
35
  "test:energy": "tsx scripts/test-dev-server-energy.ts",
36
36
  "test:shadow": "tsx scripts/test-dev-server-shadow.ts",
37
+ "test:span": "tsx scripts/test-dev-server-clickable-span.ts",
37
38
  "test:pick": "tsx scripts/test-panel-pick.ts",
38
39
  "dev:server": "tsx scripts/dev-server.ts",
39
40
  "verify": "npm run typecheck && npm run lint && npm run test && npm run build",