feat(tool/browser): 无障碍树 ref 快照与按 ref 交互 + 真实浏览器隐私护栏 + 受控 CDP 逃生舱

This commit is contained in:
mateaix 2026-07-12 14:56:02 +08:00
parent 9d6509840c
commit 60ea00dede
6 changed files with 1027 additions and 59 deletions

View File

@ -0,0 +1,142 @@
package vip.mate.tool.browser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.tool.guard.model.GuardDecision;
import vip.mate.tool.guard.model.GuardSeverity;
import vip.mate.tool.guard.model.ToolGuardAuditLogEntity;
import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper;
import java.net.URI;
import java.util.List;
/**
* Privacy guard for browser sessions attached to a user's own logged-in browser.
*
* <p>When the browser tool connects to a Chrome the user is running themselves
* (via the DevTools Protocol, process not spawned by us), that browser may have
* banking, webmail or internal-admin tabs open with live sessions. Reading such
* a page in full screenshot, arbitrary JS eval, or a text/accessibility dump
* would funnel private content into the model and possibly into persisted
* transcripts. This guard classifies the current page and refuses those
* content-reading actions on pages that look sensitive, while leaving plain
* navigation untouched. It never applies to headless or self-spawned browsers.
*
* <p>Classification precedence: configured trusted hosts (always safe)
* configured sensitive hosts a built-in keyword heuristic over host + path.
* Every block is overridable by adding the host to
* {@code mateclaw.browser.privacy.trusted-hosts}.
*/
@Slf4j
@Component
public class BrowserPrivacyGuard {
/** Keyword signals that a page handles money, identity, or privileged access. */
private static final List<String> SENSITIVE_SIGNALS = List.of(
"bank", "banking", "pay", "payment", "wallet", "checkout", "billing", "invoice",
"mail", "webmail", "signin", "login", "logon", "account", "admin", "console",
"secure", "oauth", "authorize", "password", "passport", "identity");
private final BrowserProperties properties;
private final ToolGuardAuditLogMapper auditMapper;
public BrowserPrivacyGuard(BrowserProperties properties, ToolGuardAuditLogMapper auditMapper) {
this.properties = properties;
this.auditMapper = auditMapper;
}
/**
* Decide whether a content-reading action must be refused. Returns a
* human-readable block reason, or {@code null} when the action may proceed.
*
* @param userManagedBrowser true only when attached to a Chrome the user runs themselves
* @param url the current page URL
* @param action the action being attempted (screenshot / eval / snapshot)
*/
public String blockReason(boolean userManagedBrowser, String url, String action) {
if (!properties.getPrivacy().isEnabled() || !userManagedBrowser) {
return null;
}
if (!isSensitive(url)) {
return null;
}
return "Refusing action=" + action + " on what looks like a sensitive page (" + safeHost(url)
+ ") inside your own logged-in browser, to avoid exposing private content to the"
+ " model. Plain navigation is still allowed. If this page is safe to read, add its"
+ " host to mateclaw.browser.privacy.trusted-hosts.";
}
/** True when the URL matches a configured/heuristic sensitive signal and is not trusted. */
public boolean isSensitive(String url) {
if (url == null || url.isBlank()) {
return false;
}
String host;
String path;
try {
URI uri = URI.create(url);
host = uri.getHost() == null ? "" : uri.getHost().toLowerCase();
path = uri.getPath() == null ? "" : uri.getPath().toLowerCase();
} catch (IllegalArgumentException e) {
return false;
}
if (host.isEmpty()) {
return false;
}
if (hostMatches(host, properties.getPrivacy().getTrustedHosts())) {
return false;
}
if (hostMatches(host, properties.getPrivacy().getSensitiveHosts())) {
return true;
}
String hostAndPath = host + path;
for (String sig : SENSITIVE_SIGNALS) {
if (hostAndPath.contains(sig)) {
return true;
}
}
return false;
}
/** Record a blocked read into the shared tool-guard audit log so it shows up in the audit panel. */
public void audit(String conversationId, String action, String url, String reason) {
try {
ToolGuardAuditLogEntity entity = new ToolGuardAuditLogEntity();
entity.setConversationId(conversationId);
entity.setToolName("browser_use");
entity.setDecision(GuardDecision.BLOCK.name());
entity.setMaxSeverity(GuardSeverity.HIGH.name());
entity.setToolParamsJson("{\"action\":\"" + action + "\",\"host\":\"" + safeHost(url) + "\"}");
entity.setFindingsJson("[{\"type\":\"sensitive-page\",\"reason\":\""
+ reason.replace("\"", "'") + "\"}]");
auditMapper.insert(entity);
} catch (Exception e) {
log.warn("[BrowserPrivacyGuard] Failed to record audit entry: {}", e.getMessage());
}
}
private static boolean hostMatches(String host, List<String> patterns) {
if (patterns == null) {
return false;
}
for (String p : patterns) {
if (p == null || p.isBlank()) {
continue;
}
String pat = p.trim().toLowerCase();
if (host.equals(pat) || host.endsWith("." + pat)) {
return true;
}
}
return false;
}
private static String safeHost(String url) {
try {
String h = URI.create(url).getHost();
return h == null ? "unknown" : h;
} catch (Exception e) {
return "unknown";
}
}
}

View File

@ -116,5 +116,69 @@ public class BrowserProperties {
* to avoid forcing every snapshot through the spill-and-preview path.
*/
private int snapshotMaxLength = 20_000;
/**
* Whether {@code action=snapshot} includes non-interactive structural nodes
* (headings, list items, navigation, images) in the accessibility tree.
* Interactive elements always get a reference handle; structural nodes are
* emitted without one, purely to give the model page context. Turn off to
* produce a terser tree of only actionable elements.
*/
private boolean snapshotIncludeNonInteractive = true;
/** Privacy guard for sessions attached to a user's own logged-in browser (action=connect_cdp). */
private Privacy privacy = new Privacy();
/** Raw DevTools Protocol escape hatch (action=cdp) configuration. */
private Cdp cdp = new Cdp();
/**
* Controls {@code action=cdp}, which forwards a raw Chrome DevTools Protocol
* command. Constrained by a method allowlist; content-reading methods are
* additionally subject to {@link Privacy} on user-managed browsers.
*/
@Data
public static class Cdp {
/** Master switch for action=cdp. */
private boolean enabled = true;
/**
* Allowed CDP methods. An entry is either an exact method
* ({@code "Page.navigate"}) or a domain wildcard ({@code "Input.*"}).
* Defaults to safe actuation methods; extend for advanced automation.
* Content-reading methods stay guarded by {@link Privacy} even if added.
*/
private java.util.List<String> allowedMethods = new java.util.ArrayList<>(java.util.List.of(
"Input.*",
"Page.navigate", "Page.reload", "Page.bringToFront",
"Page.getNavigationHistory", "Page.navigateToHistoryEntry"));
}
/**
* When the browser tool is attached to a user-managed Chrome (connected via
* CDP, process not spawned by us), that Chrome may have banking / email /
* internal-admin tabs open. This guard refuses content-reading actions
* (screenshot / eval / full snapshot) on pages that look sensitive, so
* private content is not funnelled into the model / persisted. It never
* affects headless or self-spawned browsers.
*/
@Data
public static class Privacy {
/** Master switch. When false, no sensitive-page blocking happens. */
private boolean enabled = true;
/**
* Extra hosts to always treat as sensitive (exact host or any subdomain),
* on top of the built-in heuristic. E.g. {@code intranet.corp.example}.
*/
private java.util.List<String> sensitiveHosts = new java.util.ArrayList<>();
/**
* Hosts to always treat as safe (exact host or any subdomain). Overrides
* both the heuristic and {@link #sensitiveHosts}. Use to un-block a page
* the heuristic flagged that you know is fine to read.
*/
private java.util.List<String> trustedHosts = new java.util.ArrayList<>();
}
}

View File

@ -0,0 +1,230 @@
package vip.mate.tool.browser;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Accessibility-tree page snapshot for browser automation.
*
* <p>Replaces the older visible-text dump with a compact accessibility tree in
* which every interactive element is tagged with a stable reference handle
* ({@code @e1}, {@code @e2}, ...). The reference is materialised as a
* {@code data-mate-ref} attribute on the live DOM node, so a follow-up
* click/type action can address the element by {@code [data-mate-ref='eN']}
* instead of forcing the model to guess a brittle CSS selector.
*
* <p>Why an injected attribute rather than a framework-native snapshot: the
* attribute approach is independent of the browser-driver version, survives
* driver upgrades, and produces a selector that plugs straight into the
* existing click/type plumbing. References stay valid only for the snapshot
* that produced them a navigation wipes the attributes, so a stale reference
* naturally resolves to "not found" and the caller is told to re-snapshot.
*/
public final class PageSnapshotScript {
private PageSnapshotScript() {
}
/**
* Injected snapshot function. Runs as {@code root.evaluate(SNAPSHOT_JS, opts)}.
* Playwright's {@code ElementHandle.evaluate} invokes the function as
* {@code fn(element, arg)} the scoped root element is the FIRST positional
* parameter (NOT {@code this}, which Playwright never binds to the element),
* and the caller's {@code opts} object is the second. {@code opts} is
* {@code {maxLen, includeNonInteractive}}.
*
* <p>Returns a JSON string {@code {tree, truncated, refs}} where:
* <ul>
* <li>{@code tree} indented accessibility tree text for the model;</li>
* <li>{@code truncated} true when output was cut at the length budget;</li>
* <li>{@code refs} the list of reference ids assigned this snapshot.</li>
* </ul>
*/
public static final String SNAPSHOT_JS = """
(rootEl, opts) => {
const maxLen = opts.maxLen;
const includeNon = opts.includeNonInteractive;
const budget = { remaining: maxLen, truncated: false };
let counter = 0;
const refs = [];
// Wipe references from a prior snapshot so ids never collide
// across generations and a navigated-away page leaves nothing behind.
document.querySelectorAll('[data-mate-ref]').forEach(function (n) {
n.removeAttribute('data-mate-ref');
});
function isVisible(el) {
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;
return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0;
}
function isInteractive(el) {
const tag = el.tagName.toLowerCase();
if (['a', 'button', 'input', 'select', 'textarea', 'summary'].includes(tag)) return true;
const role = el.getAttribute('role');
if (role && ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem',
'switch', 'textbox', 'combobox', 'option', 'searchbox', 'slider'].includes(role)) return true;
if (el.hasAttribute('onclick')) return true;
if (el.isContentEditable) return true;
const ti = el.getAttribute('tabindex');
if (ti !== null && ti !== '-1') return true;
return false;
}
function roleOf(el) {
const explicit = el.getAttribute('role');
if (explicit) return explicit;
const tag = el.tagName.toLowerCase();
switch (tag) {
case 'a': return el.hasAttribute('href') ? 'link' : 'generic';
case 'button': return 'button';
case 'select': return 'combobox';
case 'textarea': return 'textbox';
case 'summary': return 'button';
case 'input': {
const t = (el.getAttribute('type') || 'text').toLowerCase();
if (t === 'checkbox') return 'checkbox';
if (t === 'radio') return 'radio';
if (t === 'submit' || t === 'button' || t === 'reset') return 'button';
if (t === 'search') return 'searchbox';
if (t === 'hidden') return null;
return 'textbox';
}
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': return 'heading';
case 'li': return 'listitem';
case 'ul': case 'ol': return 'list';
case 'nav': return 'navigation';
case 'img': return 'img';
default: return null;
}
}
function nameOf(el) {
const aria = el.getAttribute('aria-label');
if (aria) return aria.trim();
const labelledby = el.getAttribute('aria-labelledby');
if (labelledby) {
const target = document.getElementById(labelledby);
if (target) return (target.textContent || '').trim();
}
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea') {
const ph = el.getAttribute('placeholder');
if (ph) return ph.trim();
if (el.value) return String(el.value).trim();
if (el.id) {
const lab = document.querySelector('label[for="' + (window.CSS ? CSS.escape(el.id) : el.id) + '"]');
if (lab) return (lab.textContent || '').trim();
}
// Wrapping label: <label>Customer name: <input></label> common
// and has no for= link, so climb to the nearest label ancestor.
const wrap = el.closest('label');
if (wrap) {
const wt = (wrap.textContent || '').trim().replace(/\\s+/g, ' ');
if (wt) return wt;
}
return '';
}
if (tag === 'img') {
const alt = el.getAttribute('alt');
if (alt) return alt.trim();
}
const title = el.getAttribute('title');
if (title) return title.trim();
const txt = el.textContent ? el.textContent.trim().replace(/\\s+/g, ' ') : '';
return txt;
}
function clip(s, n) {
if (!s) return '';
return s.length > n ? s.substring(0, n) + '…' : s;
}
const lines = [];
function emit(text) {
if (budget.remaining <= 0) { budget.truncated = true; return false; }
if (text.length + 1 > budget.remaining) {
budget.truncated = true;
budget.remaining = 0;
return false;
}
lines.push(text);
budget.remaining -= (text.length + 1);
return true;
}
function walk(el, depth) {
if (depth > 20 || budget.remaining <= 0) return;
if (!isVisible(el)) return;
const role = roleOf(el);
const interactive = isInteractive(el);
let line = null;
if (interactive && role !== 'generic' && role !== null) {
counter += 1;
const ref = 'e' + counter;
el.setAttribute('data-mate-ref', ref);
refs.push(ref);
const nm = clip(nameOf(el), 100);
line = role + (nm ? ' "' + nm + '"' : '') + ' @' + ref;
} else if (includeNon && role && role !== 'generic') {
const nm = clip(nameOf(el), 100);
if (nm || role === 'list' || role === 'navigation') {
let extra = '';
if (role === 'heading') {
const lvl = el.getAttribute('aria-level')
|| (el.tagName.length === 2 ? el.tagName.charAt(1) : '');
if (lvl) extra = ' [level=' + lvl + ']';
}
line = role + (nm ? ' "' + nm + '"' : '') + extra;
}
}
if (line !== null) {
if (!emit(' '.repeat(Math.min(depth, 10)) + '- ' + line)) return;
}
const childDepth = line !== null ? depth + 1 : depth;
for (const child of el.children) {
if (budget.remaining <= 0) break;
walk(child, childDepth);
}
}
walk(rootEl, 0);
return JSON.stringify({ tree: lines.join('\\n'), truncated: budget.truncated, refs: refs });
}
""";
/** Parsed result of a snapshot evaluation. */
public record Result(String tree, boolean truncated, List<String> refs) {
public static Result fromJson(String json) {
JSONObject obj = JSONUtil.parseObj(json);
String tree = obj.getStr("tree", "");
boolean truncated = obj.getBool("truncated", false);
List<String> refs = new ArrayList<>();
JSONArray arr = obj.getJSONArray("refs");
if (arr != null) {
for (Object o : arr) {
if (o != null) {
refs.add(o.toString());
}
}
}
return new Result(tree, truncated, refs);
}
}
/** Build the deterministic attribute selector for a reference id. */
public static String selectorForRef(String ref) {
return "[data-mate-ref='" + ref + "']";
}
}

View File

@ -4,8 +4,11 @@ import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.CDPSession;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
@ -20,7 +23,9 @@ import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import vip.mate.tool.browser.BrowserDiagnosticsService;
import vip.mate.tool.browser.BrowserLauncher;
import vip.mate.tool.browser.BrowserPrivacyGuard;
import vip.mate.common.net.SsrfProperties;
import vip.mate.tool.browser.PageSnapshotScript;
import vip.mate.tool.browser.UrlSafetyChecker;
import java.net.Socket;
@ -44,14 +49,16 @@ public class BrowserUseTool {
private static final int CDP_SCAN_PORT_MAX = 10000;
/**
* Snapshot extractor runs as an ElementHandle.evaluate so {@code this}
* is the scoped root (document.body when no selector is passed). Uses a
* budget object so truncation stops at element boundaries rather than
* mid-TEXT_NODE, and surfaces a {@code truncated:true} flag to the caller
* so the LLM can be told to retry with a narrower selector.
* Legacy visible-text extractor kept as a fallback: {@link #doSnapshot}
* prefers the accessibility-tree snapshot ({@link PageSnapshotScript}) and
* only falls back to this plain-text dump if the tree script throws on an
* unusual page. Runs as an ElementHandle.evaluate so {@code this} is the
* scoped root (document.body when no selector is passed). Uses a budget
* object so truncation stops at element boundaries rather than
* mid-TEXT_NODE, and surfaces a {@code truncated:true} flag.
*/
private static final String SNAPSHOT_JS = """
(maxLen) => {
private static final String TEXT_SNAPSHOT_JS_FALLBACK = """
(rootEl, maxLen) => {
const budget = { remaining: maxLen, truncated: false };
function getVisibleText(node, depth) {
if (depth > 10 || budget.remaining <= 0) return '';
@ -104,7 +111,7 @@ public class BrowserUseTool {
}
return results.join('\\n');
}
const text = getVisibleText(this, 0);
const text = getVisibleText(rootEl, 0);
return JSON.stringify({ text: text, truncated: budget.truncated });
}
""";
@ -114,15 +121,35 @@ public class BrowserUseTool {
private final BrowserLauncher launcher;
private final BrowserDiagnosticsService diagnostics;
private final SsrfProperties ssrfProperties;
private final BrowserPrivacyGuard privacyGuard;
public BrowserUseTool(vip.mate.channel.web.ChatStreamTracker streamTracker,
BrowserLauncher launcher,
BrowserDiagnosticsService diagnostics,
SsrfProperties ssrfProperties) {
SsrfProperties ssrfProperties,
BrowserPrivacyGuard privacyGuard) {
this.streamTracker = streamTracker;
this.launcher = launcher;
this.diagnostics = diagnostics;
this.ssrfProperties = ssrfProperties;
this.privacyGuard = privacyGuard;
}
/**
* Enforce the privacy guard for a content-reading action. Returns an error
* JSON string to return to the caller when the action is refused on a
* sensitive page of a user-managed browser, or {@code null} to proceed.
*/
private String guardReadOrNull(BrowserSession session, String action) {
String reason = privacyGuard.blockReason(session.isUserManagedBrowser(),
session.page.url(), action);
if (reason == null) {
return null;
}
String conversationId = ToolExecutionContext.conversationId(currentToolContext);
privacyGuard.audit(conversationId, action, session.page.url(), reason);
log.info("[BrowserUse] Privacy guard blocked action={} on {}", action, session.page.url());
return error(reason);
}
/**
@ -165,25 +192,34 @@ public class BrowserUseTool {
- start: Launch a new browser (tries system Chrome, system Edge, then Playwright bundled). Optional headed=true.
- stop: Close browser. If connected via CDP, only disconnects (Chrome keeps running).
- open: Navigate to a URL. Requires url parameter. Auto-starts browser if not running.
- snapshot: Get page text content, interactive elements, and title. Optional `selector`
scopes to a subtree USE IT when the page is large (big tables, long lists) to avoid
truncation. Without selector, content is capped and a `truncated:true` flag is returned
with a hint to retry using selector.
- snapshot: Get the page as an accessibility tree. Every interactive element (link, button,
input, ...) is tagged with a stable reference like `@e1`, `@e2`. Read the tree, then act on
an element by passing ref=<eN> to action=click/type no CSS selector guessing needed.
References belong to the returned `generation`; re-snapshot if the page changes. Optional
`selector` scopes to a subtree USE IT when the page is large to avoid truncation
(`truncated:true` is flagged with a hint).
- screenshot: Take a screenshot. Optional path to save file; returns base64 if no path.
- click: Click an element. Requires selector (CSS selector).
- type: Type text into an element. Requires selector and text.
- click: Click an element. Pass ref=<eN> from a snapshot (preferred) or a CSS selector.
- type: Type text into an element. Pass ref=<eN> (preferred) or selector, plus text.
- hover: Hover over an element (reveals menus/tooltips). Pass ref=<eN> or selector.
- select: Choose an option in a dropdown. Pass ref=<eN> or selector, plus value.
- eval: Execute JavaScript on the page. Requires code parameter. Top-level await is supported; use `return` to surface a value.
- cdp: Send a raw Chrome DevTools Protocol command. Requires method (e.g. 'Page.navigate'), optional params (JSON). Constrained by an allowlist; use only when the higher-level actions cannot express what you need.
- connect_cdp: Connect to an existing Chrome via CDP. Requires url (e.g. "http://localhost:9222").
- list_cdp_targets: Scan local ports (9000-10000) for CDP endpoints. Optional cdpPort for single port.
- navigate_back: Go back in browser history.
- diagnose: Run a self-check reports which launch strategies are available and what to install if none are.
""")
public String browser_use(
@ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action,
@ToolParam(description = "Action: start|stop|open|snapshot|screenshot|click|type|hover|select|eval|connect_cdp|list_cdp_targets|navigate_back|diagnose") String action,
@ToolParam(description = "URL to navigate to (for open), or CDP base URL (for connect_cdp, e.g. http://localhost:9222)", required = false) String url,
@ToolParam(description = "CSS selector. REQUIRED for click/type. OPTIONAL for snapshot: pass to scope to a subtree when previous snapshot returned truncated:true.", required = false) String selector,
@ToolParam(description = "CSS selector. Alternative to ref for click/type/hover/select. OPTIONAL for snapshot: pass to scope to a subtree when previous snapshot returned truncated:true.", required = false) String selector,
@ToolParam(description = "Element reference from a snapshot (e.g. 'e4'). PREFERRED for click/type/hover/select — takes priority over selector. Re-snapshot if it reports stale.", required = false) String ref,
@ToolParam(description = "Text to type (for action=type)", required = false) String text,
@ToolParam(description = "Option value or visible label to choose (for action=select)", required = false) String value,
@ToolParam(description = "JavaScript code to execute (for action=eval). Top-level await is allowed; add `return` to return a value when the snippet uses await.", required = false) String code,
@ToolParam(description = "CDP method for action=cdp (e.g. 'Page.navigate', 'Input.dispatchMouseEvent'). Must be in the allowlist.", required = false) String method,
@ToolParam(description = "JSON object of params for action=cdp (e.g. {\"url\":\"https://example.com\"})", required = false) String params,
@ToolParam(description = "File path to save screenshot (for action=screenshot)", required = false) String path,
@ToolParam(description = "Launch visible browser window (for action=start, default false)", required = false) Boolean headed,
@ToolParam(description = "Single CDP port to scan (for action=list_cdp_targets)", required = false) Integer cdpPort,
@ -199,8 +235,14 @@ public class BrowserUseTool {
return error("action is required");
}
String sessionKey = "default";
log.info("[BrowserUse] action={}, url={}, selector={}, headed={}, cdpPort={}", action, url, selector, headed, cdpPort);
// Isolate browser state per conversation so concurrent chats don't drive
// (and navigate) each other's page. Falls back to a shared key when no
// conversation context is present (e.g. internal/system invocations).
String conversationId = ToolExecutionContext.conversationId(ctx);
String sessionKey = (conversationId != null && !conversationId.isBlank())
? conversationId : "default";
log.info("[BrowserUse] action={}, session={}, url={}, selector={}, headed={}, cdpPort={}",
action, sessionKey, url, selector, headed, cdpPort);
try {
return switch (action.toLowerCase().trim()) {
@ -209,14 +251,17 @@ public class BrowserUseTool {
case "open" -> doOpen(sessionKey, url);
case "snapshot" -> doSnapshot(sessionKey, selector);
case "screenshot" -> doScreenshot(sessionKey, path);
case "click" -> doClick(sessionKey, selector);
case "type" -> doType(sessionKey, selector, text);
case "click" -> doClick(sessionKey, ref, selector);
case "type" -> doType(sessionKey, ref, selector, text);
case "hover" -> doHover(sessionKey, ref, selector);
case "select" -> doSelect(sessionKey, ref, selector, value);
case "eval" -> doEval(sessionKey, code);
case "cdp" -> doCdp(sessionKey, method, params);
case "connect_cdp" -> doConnectCdp(sessionKey, url);
case "list_cdp_targets" -> doListCdpTargets(cdpPort);
case "navigate_back" -> doNavigateBack(sessionKey);
case "diagnose" -> doDiagnose();
default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, eval, connect_cdp, list_cdp_targets, navigate_back, diagnose");
default -> error("Unknown action: " + action + ". Supported: start, stop, open, snapshot, screenshot, click, type, hover, select, eval, cdp, connect_cdp, list_cdp_targets, navigate_back, diagnose");
};
} catch (PlaywrightException e) {
log.error("[BrowserUse] Playwright error: {}", e.getMessage());
@ -520,6 +565,7 @@ public class BrowserUseTool {
}
session.touch();
session.invalidateRefs();
Page page = session.page;
page.navigate(normalizedUrl);
@ -546,6 +592,7 @@ public class BrowserUseTool {
}
session.touch();
session.invalidateRefs();
session.page.goBack();
String title = session.page.title();
@ -567,6 +614,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
String blocked = guardReadOrNull(session, "snapshot");
if (blocked != null) {
return blocked;
}
session.touch();
Page page = session.page;
@ -590,31 +642,70 @@ public class BrowserUseTool {
}
int maxLen = launcher.properties().getSnapshotMaxLength();
String jsResult = (String) root.evaluate(SNAPSHOT_JS, maxLen);
// JS returns { text: "...", truncated: true/false }
JSONObject parsed = JSONUtil.parseObj(jsResult);
String textContent = parsed.getStr("text");
boolean truncated = parsed.getBool("truncated", false);
boolean includeNon = launcher.properties().isSnapshotIncludeNonInteractive();
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("title", title);
result.set("url", url);
// IMPORTANT: truncated + hint MUST come before content. The framework's
// spill-preview keeps only the head ~800 chars of the JSON, so placing
// these flags first ensures the LLM still sees them after a spill.
result.set("truncated", truncated);
if (truncated) {
result.set("hint", "Content truncated. Re-call browser_use with action=snapshot"
+ " and selector=<CSS> to scope to a subtree (e.g. selector='#main',"
+ " selector='table tbody tr').");
try {
// Accessibility-tree snapshot: assigns stable @eN references to
// interactive elements (materialised as data-mate-ref attributes)
// so click/type can address them precisely instead of guessing a
// CSS selector. Bumps the session generation so a later action on a
// stale reference (page changed / navigated) resolves to not-found.
JSONObject opts = new JSONObject();
opts.set("maxLen", maxLen);
opts.set("includeNonInteractive", includeNon);
String jsResult = (String) root.evaluate(PageSnapshotScript.SNAPSHOT_JS, opts);
PageSnapshotScript.Result snap = PageSnapshotScript.Result.fromJson(jsResult);
int generation = session.nextSnapshotGeneration(snap.refs());
result.set("snapshotMode", "accessibility-tree");
result.set("generation", generation);
// IMPORTANT: flags/hints MUST precede the (large) tree. The framework's
// spill-preview keeps only the head of the JSON, so ordering these
// first ensures the LLM still sees them after a spill.
result.set("truncated", snap.truncated());
result.set("hint", "Interactive elements are tagged @eN. To act on one, call"
+ " action=click or action=type with ref=<eN> (e.g. ref='e4') — no CSS"
+ " selector needed. References are valid only for generation "
+ generation + "; re-snapshot if the page changes."
+ (snap.truncated() ? " Content truncated — pass selector=<CSS> to scope"
+ " to a subtree (e.g. selector='#main')." : ""));
if (selector != null && !selector.isBlank()) {
result.set("scopedTo", selector);
}
result.set("refCount", snap.refs().size());
result.set("content", snap.tree());
return JSONUtil.toJsonPrettyStr(result);
} catch (PlaywrightException e) {
// Rare pages break the tree walk (exotic custom elements, CSP on
// attribute writes). Fall back to the plain visible-text dump so the
// model still gets *something* readable, flagged so it knows refs
// are unavailable and it must use CSS selectors for this page.
log.warn("[BrowserUse] Accessibility snapshot failed, falling back to text dump: {}", e.getMessage());
// The tree script wipes data-mate-ref attributes before it walks, so a
// mid-walk failure leaves the DOM with no refs. Drop currentRefs too,
// otherwise a later ref action would pass the stale-check but resolve
// to nothing (bare timeout) instead of a clean re-snapshot prompt.
session.invalidateRefs();
String jsResult = (String) root.evaluate(TEXT_SNAPSHOT_JS_FALLBACK, maxLen);
JSONObject parsed = JSONUtil.parseObj(jsResult);
boolean truncated = parsed.getBool("truncated", false);
result.set("snapshotMode", "text-fallback");
result.set("truncated", truncated);
result.set("hint", "Accessibility tree unavailable on this page — no @eN refs."
+ " Use action=click/type with selector=<CSS>."
+ (truncated ? " Content truncated — pass selector=<CSS> to scope." : ""));
if (selector != null && !selector.isBlank()) {
result.set("scopedTo", selector);
}
result.set("content", parsed.getStr("text"));
return JSONUtil.toJsonPrettyStr(result);
}
if (selector != null && !selector.isBlank()) {
result.set("scopedTo", selector);
}
result.set("content", textContent);
return JSONUtil.toJsonPrettyStr(result);
}
private String doScreenshot(String sessionKey, String path) {
@ -623,6 +714,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
String blocked = guardReadOrNull(session, "screenshot");
if (blocked != null) {
return blocked;
}
session.touch();
Page page = session.page;
@ -654,63 +750,159 @@ public class BrowserUseTool {
}
}
private String doClick(String sessionKey, String selector) {
if (selector == null || selector.isBlank()) {
return error("selector is required for action=click");
/** Result of resolving a click/type/hover/select target: a selector, or an error to return. */
private record TargetResolution(String selector, String label, String error) {
static TargetResolution ok(String selector, String label) {
return new TargetResolution(selector, label, null);
}
static TargetResolution fail(String error) {
return new TargetResolution(null, null, error);
}
}
/**
* Resolve an action target to a CSS selector. A snapshot {@code ref} takes
* priority over an explicit CSS {@code selector}; a ref that is not part of
* the current snapshot is reported as stale so the caller re-snapshots
* instead of getting a bare element-not-found timeout.
*/
private TargetResolution resolveTarget(BrowserSession session, String ref, String selector) {
if (ref != null && !ref.isBlank()) {
String r = ref.trim();
if (!session.currentRefs.contains(r)) {
return TargetResolution.fail("ref '" + r + "' is not part of the current snapshot"
+ " (generation " + session.snapshotGeneration + "). The page likely changed,"
+ " or you have not snapshotted since it did. Call action=snapshot first,"
+ " then use a ref from that fresh result.");
}
return TargetResolution.ok(PageSnapshotScript.selectorForRef(r), r);
}
if (selector != null && !selector.isBlank()) {
return TargetResolution.ok(selector, selector);
}
return TargetResolution.fail("Either ref (from a snapshot, e.g. ref='e4') or a CSS selector is required.");
}
private String doClick(String sessionKey, String ref, String selector) {
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
Page page = session.page;
page.click(selector);
String before = page.url();
page.click(t.selector());
page.waitForLoadState(LoadState.DOMCONTENTLOADED);
String title = page.title();
String url = page.url();
// A navigation invalidates the snapshot references; a same-page click
// (toggle, expand) keeps them so the model can act on more refs.
if (!url.equals(before)) {
session.invalidateRefs();
}
log.info("[BrowserUse] Clicked: {} (page now: {})", selector, url);
log.info("[BrowserUse] Clicked: {} (page now: {})", t.label(), url);
broadcastBrowserEvent("click", true, url, title, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("selector", selector);
result.set("target", t.label());
result.set("currentUrl", url);
result.set("currentTitle", title);
result.set("message", "Clicked element: " + selector);
if (!url.equals(before)) {
result.set("navigated", true);
result.set("hint", "The page navigated — previous @eN refs are stale. Re-snapshot before acting.");
}
result.set("message", "Clicked element: " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
private String doType(String sessionKey, String selector, String text) {
if (selector == null || selector.isBlank()) {
return error("selector is required for action=type");
}
private String doType(String sessionKey, String ref, String selector, String text) {
if (text == null) {
return error("text is required for action=type");
}
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
Page page = session.page;
session.page.fill(t.selector(), text);
page.fill(selector, text);
log.info("[BrowserUse] Typed into: {} ({} chars)", selector, text.length());
log.info("[BrowserUse] Typed into: {} ({} chars)", t.label(), text.length());
broadcastBrowserEvent("type", true, null, null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("selector", selector);
result.set("target", t.label());
result.set("textLength", text.length());
result.set("message", "Typed " + text.length() + " characters into " + selector);
result.set("message", "Typed " + text.length() + " characters into " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
private String doHover(String sessionKey, String ref, String selector) {
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
session.page.hover(t.selector());
log.info("[BrowserUse] Hovered: {}", t.label());
broadcastBrowserEvent("hover", true, null, null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("target", t.label());
result.set("message", "Hovered element: " + t.label()
+ ". Re-snapshot to capture any menu/tooltip it revealed.");
return JSONUtil.toJsonPrettyStr(result);
}
private String doSelect(String sessionKey, String ref, String selector, String value) {
if (value == null || value.isBlank()) {
return error("value is required for action=select");
}
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
TargetResolution t = resolveTarget(session, ref, selector);
if (t.error() != null) {
return error(t.error());
}
session.touch();
// Playwright matches by option value, label, or visible text, so a
// human-readable value from the model works without extra hints.
List<String> chosen = session.page.selectOption(t.selector(), value);
log.info("[BrowserUse] Selected {} in {} -> {}", value, t.label(), chosen);
broadcastBrowserEvent("select", true, null, null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("target", t.label());
result.set("selected", chosen);
result.set("message", chosen.isEmpty()
? "No option matched '" + value + "'. Re-snapshot and check the option labels."
: "Selected '" + value + "' in " + t.label());
return JSONUtil.toJsonPrettyStr(result);
}
@ -737,6 +929,11 @@ public class BrowserUseTool {
return error("No browser running. Use action=start first.");
}
String blocked = guardReadOrNull(session, "eval");
if (blocked != null) {
return blocked;
}
session.touch();
Page page = session.page;
@ -777,6 +974,110 @@ public class BrowserUseTool {
return JSONUtil.toJsonPrettyStr(result);
}
/**
* CDP methods that read page/network/storage content. Even when allowlisted,
* these are refused by the privacy guard on a sensitive page of a
* user-managed browser. Entries ending in {@code .} match a whole domain.
*/
private static final List<String> CDP_CONTENT_READ = List.of(
"Network.getResponseBody", "Network.getResponseBodyForInterception",
"Network.getRequestPostData", "Network.getAllCookies", "Network.getCookies",
"Storage.", "DOMStorage.", "IndexedDB.", "CacheStorage.",
"Page.captureScreenshot", "Page.captureSnapshot", "Page.printToPDF",
"Page.getResourceContent", "Page.getResourceTree",
"DOM.getOuterHTML", "DOM.getDocument", "Runtime.evaluate", "Runtime.getProperties");
private boolean isCdpMethodAllowed(String method) {
for (String entry : launcher.properties().getCdp().getAllowedMethods()) {
if (entry == null || entry.isBlank()) {
continue;
}
String e = entry.trim();
if (e.endsWith(".*")) {
if (method.startsWith(e.substring(0, e.length() - 1))) { // "Input." prefix
return true;
}
} else if (e.equals(method)) {
return true;
}
}
return false;
}
private static boolean isCdpContentReading(String method) {
for (String p : CDP_CONTENT_READ) {
if (p.endsWith(".") ? method.startsWith(p) : method.equals(p)) {
return true;
}
}
return false;
}
private String doCdp(String sessionKey, String method, String paramsJson) {
if (!launcher.properties().getCdp().isEnabled()) {
return error("action=cdp is disabled (mateclaw.browser.cdp.enabled=false).");
}
if (method == null || method.isBlank()) {
return error("method is required for action=cdp (e.g. 'Page.navigate').");
}
BrowserSession session = requireSession(sessionKey);
if (session == null) {
return error("No browser running. Use action=start first.");
}
String m = method.trim();
if (!isCdpMethodAllowed(m)) {
return error("CDP method '" + m + "' is not allowed. Allowlist: "
+ launcher.properties().getCdp().getAllowedMethods()
+ ". Add it to mateclaw.browser.cdp.allowed-methods if you trust it.");
}
if (isCdpContentReading(m)) {
String reason = privacyGuard.blockReason(session.isUserManagedBrowser(),
session.page.url(), "cdp:" + m);
if (reason != null) {
privacyGuard.audit(ToolExecutionContext.conversationId(currentToolContext),
"cdp:" + m, session.page.url(), reason);
return error(reason);
}
}
JsonObject parsed = null;
if (paramsJson != null && !paramsJson.isBlank()) {
try {
parsed = JsonParser.parseString(paramsJson).getAsJsonObject();
} catch (RuntimeException e) {
return error("params must be a JSON object: " + e.getMessage());
}
}
session.touch();
// A fresh CDP session per call keeps the escape hatch stateless and avoids
// leaking listeners; navigation-triggering methods invalidate references.
CDPSession cdp = session.context.newCDPSession(session.page);
try {
JsonObject res = parsed != null ? cdp.send(m, parsed) : cdp.send(m);
if (m.startsWith("Page.navigate") || m.equals("Page.navigateToHistoryEntry")) {
session.invalidateRefs();
}
String out = res != null ? res.toString() : "{}";
if (out.length() > 10_000) {
out = out.substring(0, 10_000) + "\n... [truncated]";
}
log.info("[BrowserUse] CDP {} -> {} chars", m, out.length());
broadcastBrowserEvent("cdp", true, session.page.url(), null, null, 0);
JSONObject result = new JSONObject();
result.set("ok", true);
result.set("method", m);
result.set("result", out);
return JSONUtil.toJsonPrettyStr(result);
} finally {
try {
cdp.detach();
} catch (Exception ignored) {
// best-effort; the session is discarded either way
}
}
}
// ==================== CDP Helpers ====================
private boolean isPortOpen(int port) {
@ -917,6 +1218,35 @@ public class BrowserUseTool {
/** 空闲看门狗定时任务stop 时取消,避免泄漏) */
volatile ScheduledFuture<?> idleWatchdog;
/**
* Monotonic snapshot generation. Each {@code action=snapshot} bumps it and
* replaces {@link #currentRefs} with the references assigned that pass.
* A click/type by a reference not in {@link #currentRefs} is reported as
* stale, prompting the caller to re-snapshot. Safe as plain volatile
* because tool calls are serialized per executor.
*/
volatile int snapshotGeneration;
volatile java.util.Set<String> currentRefs = java.util.Set.of();
int nextSnapshotGeneration(java.util.List<String> refs) {
this.currentRefs = java.util.Set.copyOf(refs);
return ++this.snapshotGeneration;
}
/** Drop all references — the DOM they pointed at is gone (navigation). */
void invalidateRefs() {
this.currentRefs = java.util.Set.of();
}
/**
* True when this session is attached to a Chrome the user runs themselves:
* connected over CDP and NOT spawned by us. Only these carry the user's
* live logins, so the privacy guard applies only here.
*/
boolean isUserManagedBrowser() {
return connectedViaCdp && ownedProcess == null;
}
BrowserSession(Browser browser, BrowserContext context, Page page,
boolean headed, boolean connectedViaCdp, String cdpUrl,
java.nio.file.Path userDataDir, Process ownedProcess) {

View File

@ -0,0 +1,86 @@
package vip.mate.tool.browser;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Unit tests for {@link BrowserPrivacyGuard} classification and blocking.
* The audit mapper is never touched by {@code isSensitive}/{@code blockReason},
* so a null mapper is fine here.
*/
class BrowserPrivacyGuardTest {
private BrowserPrivacyGuard guardWith(List<String> sensitive, List<String> trusted) {
BrowserProperties props = new BrowserProperties();
props.getPrivacy().setSensitiveHosts(sensitive);
props.getPrivacy().setTrustedHosts(trusted);
return new BrowserPrivacyGuard(props, null);
}
@Test
@DisplayName("Heuristic flags banking / webmail / login / admin pages")
void heuristicFlagsSensitivePages() {
BrowserPrivacyGuard g = guardWith(List.of(), List.of());
assertTrue(g.isSensitive("https://www.chase.com/banking"));
assertTrue(g.isSensitive("https://mail.google.com/mail/u/0"));
assertTrue(g.isSensitive("https://github.com/login"));
assertTrue(g.isSensitive("https://example.com/account/settings"));
assertTrue(g.isSensitive("https://admin.example.com/"));
}
@Test
@DisplayName("Ordinary content pages are not flagged")
void ordinaryPagesNotFlagged() {
BrowserPrivacyGuard g = guardWith(List.of(), List.of());
assertFalse(g.isSensitive("https://example.com/products/42"));
assertFalse(g.isSensitive("https://news.example.com/article/hello-world"));
assertFalse(g.isSensitive(""));
assertFalse(g.isSensitive(null));
}
@Test
@DisplayName("trustedHosts overrides both the heuristic and sensitiveHosts")
void trustedOverridesEverything() {
BrowserPrivacyGuard g = guardWith(List.of("internal.corp"), List.of("mail.google.com"));
assertFalse(g.isSensitive("https://mail.google.com/mail"));
// subdomain of a trusted host is also trusted
BrowserPrivacyGuard g2 = guardWith(List.of(), List.of("example.com"));
assertFalse(g2.isSensitive("https://admin.example.com/login"));
}
@Test
@DisplayName("sensitiveHosts adds hosts the heuristic would miss")
void sensitiveHostsExtendHeuristic() {
BrowserPrivacyGuard g = guardWith(List.of("intranet.corp"), List.of());
assertTrue(g.isSensitive("https://intranet.corp/dashboard"));
assertTrue(g.isSensitive("https://hr.intranet.corp/"));
}
@Test
@DisplayName("blockReason only fires for a user-managed browser on a sensitive page")
void blockReasonScope() {
BrowserPrivacyGuard g = guardWith(List.of(), List.of());
// sensitive page but self-spawned browser allowed
assertNull(g.blockReason(false, "https://github.com/login", "screenshot"));
// user-managed browser but ordinary page allowed
assertNull(g.blockReason(true, "https://example.com/products", "eval"));
// user-managed browser on a sensitive page blocked
assertNotNull(g.blockReason(true, "https://github.com/login", "eval"));
}
@Test
@DisplayName("Disabling the guard allows everything")
void disabledGuardAllows() {
BrowserProperties props = new BrowserProperties();
props.getPrivacy().setEnabled(false);
BrowserPrivacyGuard g = new BrowserPrivacyGuard(props, null);
assertNull(g.blockReason(true, "https://www.chase.com/banking", "screenshot"));
}
}

View File

@ -0,0 +1,116 @@
package vip.mate.tool.browser;
import cn.hutool.json.JSONObject;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import vip.mate.common.net.SsrfProperties;
import java.util.List;
/**
* Manual end-to-end probe for {@link PageSnapshotScript}. Not a JUnit test run via
* {@code mvn -q test-compile exec:java -Dexec.mainClass=vip.mate.tool.browser.PageSnapshotProbe
* -Dexec.classpathScope=test} (use {@code test-compile}, not {@code compile}, so this
* test-scoped class is built).
*
* <p>Launches a real browser and drives the EXACT path the tool uses
* {@code page.querySelector("body").evaluate(PageSnapshotScript.SNAPSHOT_JS, opts)}
* against a known page. It exists to lock the most subtle failure mode found in
* review: Playwright's {@code ElementHandle.evaluate} passes the element as the
* FIRST POSITIONAL ARGUMENT (never as {@code this}). If the snapshot function is
* ever reverted to read {@code this}, the walk starts from the wrong root and the
* tree comes back empty (or throws) this probe then fails loudly instead of the
* regression shipping silently, the way the original {@code this}-based version did.
*
* <p>Asserts, on a fixed HTML page:
* <ul>
* <li>references are assigned (non-empty) and materialised as {@code data-mate-ref};</li>
* <li>interactive elements appear with the right role + accessible name
* (incl. wrapping-label resolution and the {@code display:none} filter);</li>
* <li>{@code [data-mate-ref='e1']} resolves back to the expected element.</li>
* </ul>
* Exits non-zero on any failed assertion.
*/
public final class PageSnapshotProbe {
private static final String HTML = """
<!doctype html><html><body>
<h1>Probe Form</h1>
<label>Customer name: <input name="cn"></label>
<a href="https://example.com/next">Learn more</a>
<select><option>One</option><option value="2">Two</option></select>
<button>Submit</button>
<div style="display:none"><button>Hidden Button</button></div>
</body></html>
""";
private static int failures = 0;
public static void main(String[] args) {
System.out.println("=== PageSnapshotScript probe ===");
BrowserProperties props = new BrowserProperties();
BrowserLauncher launcher = new BrowserLauncher(props, new SsrfProperties());
int exit = 0;
try (Playwright pw = Playwright.create()) {
BrowserLauncher.Result r = launcher.launch(pw, false);
if (!r.isSuccess()) {
System.err.println("FAIL: could not launch a browser: " + r.getFailureSummary());
System.exit(1);
}
try (Browser browser = r.getBrowser()) {
Page page = r.getPage();
page.setContent(HTML);
// EXACT tool path: element handle + Hutool JSONObject opts.
ElementHandle root = page.querySelector("body");
JSONObject opts = new JSONObject();
opts.set("maxLen", 20_000);
opts.set("includeNonInteractive", true);
String json = (String) root.evaluate(PageSnapshotScript.SNAPSHOT_JS, opts);
PageSnapshotScript.Result snap = PageSnapshotScript.Result.fromJson(json);
System.out.println("tree:\n" + snap.tree());
System.out.println("refs: " + snap.refs());
List<String> refs = snap.refs();
String tree = snap.tree();
// The regression guard: the this-vs-first-arg bug makes this empty.
check(!refs.isEmpty(), "references assigned (element passed as first arg, not `this`)");
check(refs.size() >= 4, "at least 4 interactive refs (input, link, select, button), got " + refs.size());
check(tree.contains("textbox \"Customer name:\""), "input resolves wrapping-label name");
check(tree.contains("link \"Learn more\""), "anchor rendered as link with text");
check(tree.contains("combobox"), "select rendered as combobox");
check(tree.contains("button \"Submit\""), "button rendered with text");
check(tree.contains("heading \"Probe Form\" [level=1]"), "h1 rendered as heading level 1");
check(!tree.contains("Hidden Button"), "display:none subtree is filtered out");
boolean e1Resolves = page.querySelector("[data-mate-ref='e1']") != null;
check(e1Resolves, "[data-mate-ref='e1'] resolves back to a live element");
if (failures == 0) {
System.out.println("\nALL CHECKS PASSED (" + refs.size() + " refs) via " + r.getStrategy());
} else {
System.err.println("\n" + failures + " CHECK(S) FAILED");
exit = 1;
}
}
} catch (Exception e) {
System.err.println("FAIL: probe threw: " + e);
e.printStackTrace();
exit = 1;
}
System.exit(exit);
}
private static void check(boolean ok, String what) {
System.out.println((ok ? " [PASS] " : " [FAIL] ") + what);
if (!ok) {
failures++;
}
}
}