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 +703 -95
- package/lib/types/browser/session/semantic.d.ts +2 -0
- package/lib/types/browser/session/target.d.ts +3 -1
- package/lib/types/browser/session.d.ts +5 -2
- package/lib/types/browser/tools/collaboration.d.ts +25 -3
- package/lib/types/browser/tools/interaction.d.ts +2 -0
- package/lib/types/browser/tools/navigation.d.ts +4 -1
- package/lib/types/browser/tools/testing.d.ts +23 -1
- package/lib/types/browser/types.d.ts +6 -0
- package/lib/types/browser/walker.d.ts +1 -0
- package/package.json +2 -1
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
|
-
|
|
1360
|
-
|
|
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
|
|
1419
|
-
const
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
2817
|
-
|
|
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)
|
|
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
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
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
|
|
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
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
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
|
|
3639
|
-
const
|
|
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
|
|
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() !== "")
|
|
4148
|
-
|
|
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
|
|
4162
|
-
|
|
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
|
-
|
|
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")
|
|
4752
|
-
|
|
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
|
-
*
|
|
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
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
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:
|
|
5057
|
-
|
|
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
|
|
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 标徽编号,看图定位)
|
|
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(意图描述,如 '用户名'/'搜索')
|
|
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: {
|
|
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 或
|
|
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: {
|
|
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 或
|
|
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 登录态会持久化)
|
|
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
|
]
|