From 800799177e42e0c290377aa74bf6aacc0d201293 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:42:54 -0700 Subject: [PATCH 1/3] Add accessible role and name snapshot filters --- CHANGELOG.md | 1 + README.md | 2 +- docs/mcp.md | 2 +- host-rs/crates/agenttab-protocol/src/lib.rs | 96 ++++++++++ packages/extension/src/accessibility-query.ts | 13 ++ packages/extension/src/browser.ts | 18 +- packages/extension/src/page-agent-protocol.ts | 6 +- packages/extension/src/page-agent.ts | 7 +- packages/extension/src/protocol.ts | 8 +- packages/extension/test/extension.test.ts | 181 +++++++++++++++++- .../extension/test/protocol-validator.test.ts | 23 +++ packages/mcp/src/server.ts | 2 +- packages/omp/src/index.ts | 6 +- packages/omp/src/pi-schema.ts | 2 +- packages/omp/test/extension.test.ts | 15 ++ packages/sdk-typescript/src/index.ts | 2 +- schemas/rpc/v1/browser-snapshot.schema.json | 11 +- tests/architecture/verify_protocol_schemas.py | 18 +- 18 files changed, 395 insertions(+), 18 deletions(-) create mode 100644 packages/extension/src/accessibility-query.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ac91e79..134ad55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Fixed a task tab with a "Leave site?" prompt stalling the extension. Chrome's `tabs.remove` waits until the tab is gone, and a page's beforeunload prompt, which a background tab shows only once activated, can hold the tab open indefinitely. `browser_finish` and task close waited on that removal inside the ownership queue, so every later ownership change, `browser_open`, `browser_tabs`, tab event, and native reconnect stalled behind it until the extension was reloaded. Task cleanup still removes ownership before closing tabs, now waits at most two seconds for Chrome to close them, and leaves a tab still waiting on its prompt out of both `closed_tab_ids` and `retained_tab_ids`; the tab closes if the user confirms leaving. A `browser_act` `close` waits the same two seconds and then returns `close_pending: true` instead of holding its request open. - No single extension operation can stall the operations queued behind it any more. An ownership change or attention-notice change still running after 10 seconds, or a background tab or alarm event after 30 seconds, keeps running and is logged, but the next operation starts. Ten seconds covers the two-second tab removal wait with room for normal Chrome calls and stays well under the host's 30-second command deadline, so a command queued behind a stuck operation can still answer. - `browser_finish` and task close now report an extension that does not answer in time as `extension_timeout`, telling the agent that the task is still open and to reload the extension if it stays unresponsive, then finish or close again. They previously reported `state_store_error` and suggested repairing `~/.agenttab` or freeing disk space. When the extension refuses cleanup, such as for a `keep_tab_ids` entry the task does not own, the error now carries the extension's own code and message instead of `native_protocol_error`. Disconnects and protocol or transport failures during cleanup keep their own error codes too. +- Added accessibility-only `browser_snapshot.query` filters for exact case-insensitive AX roles and case-insensitive accessible-name substrings (not descriptions or values). Filtering preserves actionable refs and frame IDs, composes with `root_ref`/`frame_id`, and applies `max_nodes`/`truncated` to matching nodes, including DOM editable fallbacks and the scripting outline. Core RPC, MCP, OMP, Pi, and the TypeScript SDK expose the same optional query. - `scripts/reload_unpacked_extension.sh` now reaches the Chrome instance and profile that have the extension. It previously handed the wake page to whichever same-named Chrome instance macOS picked, so a second Chrome (such as a headless test browser) could receive it, nothing reloaded, and `dev:deploy` still reported success. The script now finds the profiles whose preferences list the extension in the application's default user data directory, opens the wake page in each through Chrome's own instance handoff, and exits with an error when no profile has the extension. Use `--user-data-dir` or `--profile-directory` (or `AGENTTAB_CHROME_USER_DATA_DIR` and `AGENTTAB_CHROME_PROFILE_DIRECTORY`) to override the detection. - `context_click` no longer opens Chrome's native context menu. On macOS that menu stalled every AgentTab request until someone dismissed it. Page `contextmenu` handlers still run, though they see the event as already handled. The suppression applies only to that one right click and expires on its own after five seconds. - Snapshot refs now survive a service worker restart or extension reload. The extension stores each snapshot's refs before returning it, drops them when their tab closes or navigates to a newer page revision, and clears them when Chrome starts. A ref still resolves only while its page document is the same one the snapshot saw. A ref the extension never stored fails with `stale_ref`, saying it is unknown or expired and asking for a new snapshot. diff --git a/README.md b/README.md index 13310db..3689978 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Standard mode exposes exactly ten tools: | Tool | Purpose | |---|---| | `browser_open` | Create a task tab, create an unfocused window for a new task, or explicitly adopt the active tab. Reports whether the resulting tab supports `full` or `tab_only` automation. | -| `browser_snapshot` | Read an accessibility tree, bounded text or HTML, or a screenshot from a full-route task tab. | +| `browser_snapshot` | Read an accessibility tree, bounded text or HTML, or a screenshot from a full-route task tab. Accessibility mode can filter actionable refs with `query: { role: "button", name: "More message options" }` (case-insensitive exact role and accessible-name substring). | | `browser_act` | Run typed actions against one task tab and expected page revision. Restricted-origin task tabs retain only navigation, history, reload, and close actions. | | `browser_wait` | Wait for load, URL, text, selector, network-idle, task-attributed download, or challenge-cleared conditions supported by the tab's route. | | `browser_tabs` | List only tabs owned by the current task, including each tab's automation route. | diff --git a/docs/mcp.md b/docs/mcp.md index 8772cc7..c1edc4a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,7 +63,7 @@ For MCP, the capability store namespace is `mcp`; OMP uses `omp`; Pi uses `pi`. | Tool | Required input and behavior | |---|---| | `browser_open` | `mode: "create"` optionally accepts an `http`, `https`, or `about` URL, `background`, and `placement`. The default `placement: "task"` creates a tab in the task's existing window when possible. `placement: "new_window"` creates the first tab of an otherwise empty task in a separate unfocused normal window and rejects `background: false`. `mode: "adopt_active"` explicitly adopts only the currently active tab. The result includes task, tab, window, page-revision, and `automation_route` identifiers. It returns after the tab's first load completes, up to 10 seconds, so its `page_revision` and `url` describe the loaded page and can be passed straight to `browser_act`. | -| `browser_snapshot` | Requires `tab_id`. Modes are `accessibility`, `text`, `html`, and `screenshot`; each may scope to an opaque CDP `frame_id`. Only accessibility snapshots return revisioned node references, each bound to its originating frame; full-tree accessibility snapshots also append `dom_fallback` textbox nodes for editable elements that never entered Chrome's accessibility tree, so those refs still address the existing typed actions. Screenshot requests may select `png`, `jpeg`, or `webp`, set JPEG/WebP `quality`, bound dimensions with `max_width`/`max_height`, and cap compressed image size with `max_bytes`. Snapshots require the `full` automation route. | +| `browser_snapshot` | Requires `tab_id`. Modes are `accessibility`, `text`, `html`, and `screenshot`; each may scope to an opaque CDP `frame_id`. Only accessibility snapshots return revisioned node references, each bound to its originating frame; full-tree accessibility snapshots also append `dom_fallback` textbox nodes for editable elements that never entered Chrome's accessibility tree, so those refs still address the existing typed actions. Accessibility mode optionally accepts `query: { role?: string, name?: string }`: `role` is an exact case-insensitive AX role (1–256 characters), and `name` is a case-insensitive substring of the accessible name (1–2048 characters), not the description or value. Both predicates must match when supplied; an empty query leaves the snapshot unfiltered. Filtering works with `root_ref` and `frame_id`, preserves actionable refs and frame IDs, and applies `max_nodes` to matching nodes, setting `truncated` when more matches exist. For example, `{ tab_id: 1, mode: "accessibility", query: { role: "button", name: "More message options" }, max_nodes: 10 }` locates controls without reading the entire returned tree. Other modes reject `query`, as they reject `root_ref`/`max_nodes`. Screenshot requests may select `png`, `jpeg`, or `webp`, set JPEG/WebP `quality`, bound dimensions with `max_width`/`max_height`, and cap compressed image size with `max_bytes`. Snapshots require the `full` automation route. | | `browser_act` | Requires `tab_id`, `expected_page_revision`, and one to 64 typed actions. Actions are click, hover, double-click, context-click, press, type, fill, select, scroll, drag, navigate, history movement, reload, close, dialog decision, and staged file upload. Element-target actions accept exactly one `ref` or `selector` and optional `frame_id`; `press` accepts the Standard navigation/editing keys plus optional unique `Alt`, `Control`, `Meta`, and `Shift` modifiers. Lowercase `a`, `z`, and `y` require exactly one of Control or Meta, optionally Shift, never Alt. No coordinate action, generic key injection, clipboard shortcut, or cross-frame target is exposed in Standard mode. When a step fails, the error `details` carry `failed_action_index` (zero-based) and `completed_actions`, the results of the steps that ran first; in a batch of more than one action, the message also names the failing step. A failure after at least one completed step has `outcome: "unknown"`, because those steps already changed the page. A `close` releases the tab from the task and returns `completed: true` once Chrome closes it, or `started: true, close_pending: true` when the tab is still open two seconds later, as when a "Leave site?" prompt holds it; the tab then closes if the user confirms leaving. An optional `intent`, one line of 1 to 120 characters, replaces the agent pointer's generated caption for every action in the batch, for example "Open the bookmarks folder". It is visible on screen, so it must never carry secrets or field values. | | `browser_wait` | Requires `tab_id` and one load, URL, text, selector, value, network-idle, download, or `challenge_cleared` condition. Selector waits may set `attached`, `detached`, `visible`, `hidden`, or `enabled` state and scope to a `frame_id`; value waits require selector and expected value and may scope to a frame. A download wait may resume after only its opaque tab-owned `after` cursor. A `challenge_cleared` wait takes the `challenge_id` of an active challenge episode on the tab and completes with `matched: true` once the episode clears or is superseded; if the episode reports it no longer expects automatic clearing, the wait returns early with outcome `needs_user` and result `status: "challenge_needs_user"`; an unknown, ended, or other-task episode fails with `challenge_not_found` and outcome `not_started`, and a timeout carries the latest event in `error.details.challenge`. `timeout_ms` is at most 120 seconds. Load and URL waits also follow Chrome's top-frame navigation events, with polling as a fallback: a load condition matches a top-frame load that completes after the wait starts, and a URL condition also matches committed navigations, same-document history changes (`pushState` and `replaceState`), and fragment changes seen during the wait. A top-frame navigation error during a load or URL wait fails with `navigation_failed`, `outcome: "unknown"`, and Chrome's network error name, never the URL; a superseded or cancelled navigation does not fail the wait. A `tab_only` route accepts load and URL conditions only; frame-scoped reads, value/selector state, network-idle, and download attribution, and challenge waits require the tab-scoped debugger connection available on the `full` route. | | `browser_tabs` | Takes an empty object and lists only the current task's tabs, including each tab's `automation_route`. | diff --git a/host-rs/crates/agenttab-protocol/src/lib.rs b/host-rs/crates/agenttab-protocol/src/lib.rs index c7db468..0ff8c64 100644 --- a/host-rs/crates/agenttab-protocol/src/lib.rs +++ b/host-rs/crates/agenttab-protocol/src/lib.rs @@ -422,6 +422,38 @@ fn default_true() -> bool { true } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccessibilityQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +// Serde's derived struct visitor also accepts sequences; the wire query must be an object. +fn deserialize_accessibility_query<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct QueryObject; + impl<'de> serde::de::Visitor<'de> for QueryObject { + type Value = AccessibilityQuery; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an accessibility query object") + } + + fn visit_map(self, map: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + AccessibilityQuery::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + } + } + deserializer.deserialize_map(QueryObject).map(Some) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] pub enum BrowserSnapshotParams { @@ -435,6 +467,8 @@ pub enum BrowserSnapshotParams { max_depth: Option, #[serde(default, skip_serializing_if = "Option::is_none")] max_nodes: Option, + #[serde(default, deserialize_with = "deserialize_accessibility_query", skip_serializing_if = "Option::is_none")] + query: Option, }, Text { tab_id: u64, @@ -1132,6 +1166,7 @@ impl MethodParams { root_ref, max_depth, max_nodes, + query, .. }) => { validate_optional_frame_id(method, frame_id)?; @@ -1152,6 +1187,14 @@ impl MethodParams { "max_nodes must be between 1 and 5000", )?; } + if let Some(query) = query { + if let Some(role) = &query.role { + require_len(method, role, 1, 256, "query.role")?; + } + if let Some(name) = &query.name { + require_len(method, name, 1, 2_048, "query.name")?; + } + } } Self::Snapshot(BrowserSnapshotParams::Text { frame_id, @@ -2966,6 +3009,59 @@ mod tests { } } + #[test] + fn snapshot_accessibility_queries_are_bounded_and_mode_specific() { + for query in [ + json!({}), + json!({"role": "BUTTON"}), + json!({"name": "message"}), + json!({"role": "button", "name": "More message options"}), + ] { + let params = json!({ + "tab_id": 1, "mode": "accessibility", "frame_id": "frame-1", + "root_ref": "rt1.root", "max_nodes": 1, "query": query, + }); + let (_, parsed) = RpcRequest::parse(request("browser_snapshot", params.clone(), false)).unwrap(); + assert_eq!(parsed.value(), params); + } + for query in [ + json!(null), + json!([]), + json!(["button", "options"]), + json!("button"), + json!(1), + json!({"role": null}), + json!({"name": null}), + json!({"role": 1}), + json!({"name": true}), + json!({"role": ""}), + json!({"name": ""}), + json!({"role": "x".repeat(257)}), + json!({"name": "x".repeat(2049)}), + json!({"description": "message"}), + json!({"role": "button", "extra": true}), + ] { + assert!( + RpcRequest::parse(request( + "browser_snapshot", + json!({"tab_id": 1, "mode": "accessibility", "query": query}), + false, + )) + .is_err() + ); + } + for mode in ["text", "html", "screenshot"] { + assert!( + RpcRequest::parse(request( + "browser_snapshot", + json!({"tab_id": 1, "mode": mode, "query": {"role": "button"}}), + false, + )) + .is_err() + ); + } + } + #[test] fn runtime_constraints_reject_schema_invalid_limits() { for (method, params, mutation) in [ diff --git a/packages/extension/src/accessibility-query.ts b/packages/extension/src/accessibility-query.ts new file mode 100644 index 0000000..030c01d --- /dev/null +++ b/packages/extension/src/accessibility-query.ts @@ -0,0 +1,13 @@ +export interface AccessibilityQuery { + role?: string; + name?: string; +} + +/** Match the exposed accessible name only, not description or value. */ +export function accessibilityMatcher(query: AccessibilityQuery | undefined): (role: string, name: string) => boolean { + const role = query?.role?.toLowerCase(); + const name = query?.name?.toLowerCase(); + return (nodeRole, nodeName) => + (role === undefined || nodeRole.toLowerCase() === role) && + (name === undefined || nodeName.toLowerCase().includes(name)); +} diff --git a/packages/extension/src/browser.ts b/packages/extension/src/browser.ts index 1ce949d..f7b114d 100644 --- a/packages/extension/src/browser.ts +++ b/packages/extension/src/browser.ts @@ -1,4 +1,5 @@ import { RevisionTracker } from "./revisions"; +import { accessibilityMatcher, type AccessibilityQuery } from "./accessibility-query"; import { dispatchDomClick, dispatchDomFill, @@ -1076,6 +1077,7 @@ export class StandardBrowserRuntime { max_nodes: maxNodes, max_depth: maxDepth, ...(root !== undefined ? { root_id: root.backendNodeId } : {}), + ...(params.query !== undefined ? { query: params.query as AccessibilityQuery } : {}), }, root?.documentId); await this.assertScriptingRevision(tabId, pageRevision, "Page changed while the accessibility outline was captured"); const outline = isRecord(value) ? value : {}; @@ -1091,6 +1093,7 @@ export class StandardBrowserRuntime { taskId, route: "scripting", }), + frame_id: SCRIPTING_FRAME_ID, } : {}), ...node, @@ -1411,13 +1414,22 @@ export class StandardBrowserRuntime { ...(frameId !== undefined ? { frameId } : {}), }, false, frameDocument.sessionId); const nodes = (Array.isArray(result.nodes) ? result.nodes.filter(isRecord) : []) as AxNode[]; + const matches = accessibilityMatcher(params.query as AccessibilityQuery | undefined); + const matchingNodes = params.query === undefined ? nodes : nodes.filter((node) => matches( + typeof node.role?.value === "string" ? node.role.value : "unknown", + typeof node.name?.value === "string" ? node.name.value : "", + )); const axBackendIds = new Set(); for (const node of nodes) { if (typeof node.backendDOMNodeId === "number") axBackendIds.add(node.backendDOMNodeId); } - const domFallbackNodes = rootTarget === undefined + const fallbackNodes = rootTarget === undefined ? await this.#domEditableFallbackNodes(tabId, pageRevision, axBackendIds, frameDocument, taskId) : []; + const domFallbackNodes = params.query === undefined ? fallbackNodes : fallbackNodes.filter((node) => matches( + typeof node.role === "string" ? node.role : "unknown", + typeof node.name === "string" ? node.name : "", + )); const after = await this.frameDocument(tabId, frameDocument.frameId); if (frameDocument.documentId !== after.documentId) { const currentPageRevision = isTopFrame @@ -1436,7 +1448,7 @@ export class StandardBrowserRuntime { pageRevision, taskId, }); - const encoded = nodes.slice(0, Math.max(0, maxNodes - domFallbackNodes.length)).map((node) => ({ + const encoded = matchingNodes.slice(0, Math.max(0, maxNodes - domFallbackNodes.length)).map((node) => ({ ...(typeof node.backendDOMNodeId === "number" ? { ref: mintRef(node.backendDOMNodeId) } : {}), ...(typeof node.backendDOMNodeId === "number" ? { frame_id: frameDocument.frameId } : {}), role: typeof node.role?.value === "string" ? node.role.value : "unknown", @@ -1475,7 +1487,7 @@ export class StandardBrowserRuntime { ...(pendingFrames.length > 0 ? { frames_truncated: true, frames_limit: 256 } : {}), mode, nodes: combined.slice(0, maxNodes), - truncated: nodes.length + domFallbackNodes.length > maxNodes, + truncated: matchingNodes.length + domFallbackNodes.length > maxNodes, ...(domFallbackNodes.length > 0 ? { dom_fallback_nodes: domFallbackNodes.length } : {}), }); } diff --git a/packages/extension/src/page-agent-protocol.ts b/packages/extension/src/page-agent-protocol.ts index 3855e3d..b895bde 100644 --- a/packages/extension/src/page-agent-protocol.ts +++ b/packages/extension/src/page-agent-protocol.ts @@ -1,3 +1,5 @@ +import type { AccessibilityQuery } from "./accessibility-query"; + /** * Contract between the extension worker and the scripting page agent, the * route AgentTab uses when Chrome refuses its debugger for a tab. The agent @@ -6,7 +8,7 @@ */ /** Bump whenever a request or response shape changes, so a stale agent reinstalls. */ -export const PAGE_AGENT_VERSION = 2; +export const PAGE_AGENT_VERSION = 3; /** Bundled agent script, injected with `chrome.scripting.executeScript({ files })`. */ export const PAGE_AGENT_FILE = "page-agent.js"; @@ -14,7 +16,7 @@ export type PageAgentActionKind = "click" | "type" | "fill" | "select" | "press" export type PageAgentPointerKind = "hover" | "double_click" | "context_click"; export type PageAgentRequest = - | { op: "snapshot"; max_nodes: number; max_depth: number; root_id?: number } + | { op: "snapshot"; max_nodes: number; max_depth: number; root_id?: number; query?: AccessibilityQuery } | { op: "select"; selector: string } | { op: "probe"; id: number } | { op: "describe"; id: number; requested_value: string | null } diff --git a/packages/extension/src/page-agent.ts b/packages/extension/src/page-agent.ts index 28205c4..286bc8e 100644 --- a/packages/extension/src/page-agent.ts +++ b/packages/extension/src/page-agent.ts @@ -15,6 +15,7 @@ import { type PageAgentRequest, } from "./page-agent-protocol"; import { sensitiveFieldState } from "./sensitive-field"; +import { accessibilityMatcher, type AccessibilityQuery } from "./accessibility-query"; // Work bound so a snapshot of a pathological document stays responsive. const SNAPSHOT_INSPECT_LIMIT = 25_000; @@ -232,7 +233,8 @@ function encodeNode(element: Element, role: string, name: string, id: number): R * and not hidden from assistive technology. It is an approximation of * Chrome's accessibility tree, which only the debugger can read. */ -function snapshot(registry: Registry, maxNodes: number, maxDepth: number, rootId?: number): unknown { +function snapshot(registry: Registry, maxNodes: number, maxDepth: number, rootId?: number, query?: AccessibilityQuery): unknown { + const matches = accessibilityMatcher(query); let root: Element; if (rootId !== undefined) { const resolved = lookup(registry, rootId); @@ -254,6 +256,7 @@ function snapshot(registry: Registry, maxNodes: number, maxDepth: number, rootId if (!isRendered(element)) return; const name = accessibleName(element, role); if (NAMED_ONLY_ROLES[role] === true && !name) return; + if (!matches(role, name)) return; nodes.push(encodeNode(element, role, name, register(registry, element))); }; const walk = (parent: Element | ShadowRoot, depth: number): void => { @@ -395,7 +398,7 @@ function fillCredential(element: Element, value: string, kind: string): unknown async function run(registry: Registry, request: PageAgentRequest): Promise { switch (request.op) { case "snapshot": - return snapshot(registry, request.max_nodes, request.max_depth, request.root_id); + return snapshot(registry, request.max_nodes, request.max_depth, request.root_id, request.query); case "select": { const element = selectUnique(request.selector); return isAgentError(element) ? element : { id: register(registry, element) }; diff --git a/packages/extension/src/protocol.ts b/packages/extension/src/protocol.ts index 367768a..6b45931 100644 --- a/packages/extension/src/protocol.ts +++ b/packages/extension/src/protocol.ts @@ -406,6 +406,7 @@ function assertSnapshotParams(value: unknown): Record { "root_ref", "max_depth", "max_nodes", + "query", "selector", "max_bytes", "match", @@ -419,11 +420,16 @@ function assertSnapshotParams(value: unknown): Record { ); assertTabId(params.tab_id); if (params.mode === "accessibility") { - assertExactObject(params, ["tab_id", "mode"], ["frame_id", "root_ref", "max_depth", "max_nodes"], "accessibility snapshot parameters"); + assertExactObject(params, ["tab_id", "mode"], ["frame_id", "root_ref", "max_depth", "max_nodes", "query"], "accessibility snapshot parameters"); assertFrameId(params.frame_id); if (params.root_ref !== undefined) assertBoundedString(params.root_ref, "root_ref", 1, 256); if (params.max_depth !== undefined && !isIntegerInRange(params.max_depth, 1, 200)) commandError("max_depth must be between 1 and 200"); if (params.max_nodes !== undefined && !isIntegerInRange(params.max_nodes, 1, 5_000)) commandError("max_nodes must be between 1 and 5000"); + if (params.query !== undefined) { + const query = assertExactObject(params.query, [], ["role", "name"], "accessibility query"); + if (query.role !== undefined) assertBoundedString(query.role, "query.role", 1, 256); + if (query.name !== undefined) assertBoundedString(query.name, "query.name", 1, 2_048); + } return params; } if (params.mode === "text" || params.mode === "html") { diff --git a/packages/extension/test/extension.test.ts b/packages/extension/test/extension.test.ts index 4f61c3c..2654bf5 100644 --- a/packages/extension/test/extension.test.ts +++ b/packages/extension/test/extension.test.ts @@ -19,6 +19,7 @@ import { runPageAgentRequest, type PageAgentRequest, } from "../src/page-agent-protocol"; +import { installPageAgent } from "../src/page-agent"; const LEGACY_TASKS_KEY = "chromeBridgeTaskSessions"; const LEGACY_PREFERENCES_KEY = "chromeBridgePreferences"; @@ -1914,6 +1915,132 @@ describe("page revision monotonicity", () => { await runtime.detach(72); }); + test("filters AX role and accessible name before the node budget and preserves actionable refs", async () => { + const axNode = (id: number, role: string, name: string, description?: string) => ({ + nodeId: String(id), + backendDOMNodeId: id, + role: { value: role }, + name: { value: name }, + ...(description !== undefined ? { description: { value: description } } : {}), + }); + const nodes = [ + axNode(1, "RootWebArea", "Inbox"), + ...Array.from({ length: 80 }, (_, index) => axNode(index + 10, "generic", `Message ${index}`)), + axNode(201, "button", "More message options"), + axNode(202, "BUTTON", "More MESSAGE options for thread"), + axNode(203, "button", "Archive", "More message options"), + axNode(204, "link", "More message options"), + axNode(205, "menuitembutton", "More message options"), + ]; + debuggerCommandOverride = (method, params) => { + if (method === "Accessibility.getFullAXTree") return { nodes }; + if (method === "DOM.querySelectorAll") return { nodeIds: [300] }; + if (method === "DOM.describeNode" && params.nodeId === 300) { + return { node: { backendNodeId: 300, localName: "textarea", attributes: ["placeholder", "Message"] } }; + } + if (method === "Runtime.callFunctionOn" && String(params.functionDeclaration).includes("const f=this.form")) { + return { result: { value: { tag: "BUTTON", text: "More message options" } } }; + } + return undefined; + }; + const runtime = new StandardBrowserRuntime(new RevisionTracker(), async () => undefined, () => undefined, async () => undefined); + const query = { role: "BuTtOn", name: "MESSAGE OPTIONS" }; + const snapshot = await runtime.snapshot(TASK_A, 70, { mode: "accessibility", query, max_nodes: 2 }); + const matches = snapshot.nodes as Array>; + expect(matches.map((node) => node.name)).toEqual(["More message options", "More MESSAGE options for thread"]); + expect(snapshot.truncated).toBe(false); + expect(snapshot.dom_fallback_nodes).toBeUndefined(); + for (const [index, node] of matches.entries()) { + expect(node.frame_id).toBe("top"); + expect(await targetRefs.lookup(node.ref)).toMatchObject({ + backendNodeId: 201 + index, frameId: "top", tabId: 70, taskId: TASK_A, + }); + } + const action = await runtime.act(TASK_A, 70, snapshot.page_revision, [{ kind: "click", ref: matches[0]!.ref }]); + expect(action.result?.actions).toEqual([{ kind: "click", completed: true }]); + expect(debuggerCommands.some(({ method, params }) => method === "DOM.resolveNode" && params.backendNodeId === 201)).toBe(true); + + const limited = await runtime.snapshot(TASK_A, 70, { mode: "accessibility", query, max_nodes: 1 }); + expect(limited.nodes).toEqual([expect.objectContaining({ + role: "button", name: "More message options", ref: expect.any(String), frame_id: "top", + })]); + expect(limited.truncated).toBe(true); + const absent = await runtime.snapshot(TASK_A, 70, { + mode: "accessibility", query: { name: "not present" }, max_nodes: 1, + }); + expect(absent.nodes).toEqual([]); + expect(absent.truncated).toBe(false); + const fallback = await runtime.snapshot(TASK_A, 70, { + mode: "accessibility", query: { role: "TEXTBOX", name: "message" }, max_nodes: 1, + }); + expect(fallback.nodes).toEqual([expect.objectContaining({ role: "textbox", name: "Message", dom_fallback: true })]); + expect(fallback.truncated).toBe(false); + await runtime.detach(70); + }); + + test("combines an AX query with a frame-scoped root ref without widening the capture", async () => { + const rootRef = mintedRef(TASK_A, 71, 1, 90); + debuggerCommandOverride = (method, params) => { + if (method === "Accessibility.getPartialAXTree") { + expect(params).toEqual({ backendNodeId: 90, fetchRelatives: true }); + return { nodes: [ + { nodeId: "90", backendDOMNodeId: 90, role: { value: "group" }, name: { value: "Message" } }, + { nodeId: "91", backendDOMNodeId: 91, role: { value: "button" }, name: { value: "More message options" } }, + ] }; + } + return undefined; + }; + const runtime = new StandardBrowserRuntime(new RevisionTracker(), async () => undefined, () => undefined, async () => undefined); + const snapshot = await runtime.snapshot(TASK_A, 71, { + mode: "accessibility", root_ref: rootRef, frame_id: "top", query: { role: "button", name: "options" }, max_nodes: 1, + }); + expect(snapshot.nodes).toEqual([expect.objectContaining({ role: "button", name: "More message options", frame_id: "top" })]); + expect(snapshot.truncated).toBe(false); + const [node] = snapshot.nodes as Array>; + expect(await targetRefs.lookup(node!.ref)).toMatchObject({ backendNodeId: 91, frameId: "top" }); + expect(debuggerCalls).not.toContain("Accessibility.getFullAXTree"); + expect(debuggerCalls).not.toContain("DOM.querySelectorAll"); + await runtime.detach(71); + }); + + test("filters a child-frame AX snapshot and keeps refs bound to that frame", async () => { + debuggerCommandOverride = (method) => { + if (method === "Runtime.enable") { + emitDebuggerEvent(73, "Runtime.executionContextCreated", { + context: { id: 42, auxData: { frameId: "child" } }, + }); + } + if (method === "Page.getFrameTree") return { frameTree: { + frame: { id: "top", loaderId: "loader-default" }, + childFrames: [{ frame: { id: "child", loaderId: "child-loader" } }], + } }; + if (method === "Runtime.evaluate") return { result: { objectId: "child-document" } }; + if (method === "DOM.requestNode") return { nodeId: 400 }; + if (method === "DOM.describeNode") return { node: { backendNodeId: 400 } }; + if (method === "Accessibility.getFullAXTree") return { nodes: [ + { nodeId: "401", backendDOMNodeId: 401, role: { value: "StaticText" }, name: { value: "Message" } }, + { nodeId: "402", backendDOMNodeId: 402, role: { value: "button" }, name: { value: "More message options" } }, + ] }; + return undefined; + }; + const runtime = new StandardBrowserRuntime(new RevisionTracker(), async () => undefined, () => undefined, async () => undefined); + const result = await runtime.snapshot(TASK_A, 73, { + mode: "accessibility", frame_id: "child", query: { role: "button", name: "options" }, max_nodes: 1, + }); + expect(result.nodes).toEqual([expect.objectContaining({ + role: "button", name: "More message options", frame_id: "child", ref: expect.any(String), + })]); + const [node] = result.nodes as Array>; + expect(await targetRefs.lookup(node!.ref)).toMatchObject({ + backendNodeId: 402, frameId: "child", documentId: "child:child-loader:400", + }); + expect(result.truncated).toBe(false); + expect(debuggerCommands).toContainEqual({ + method: "Accessibility.getFullAXTree", params: { depth: 50, frameId: "child" }, + }); + await runtime.detach(73); + }); + test("appends DOM fallback refs for editables missing from the accessibility tree", async () => { tabStore.set(66, { id: 66, @@ -6374,6 +6501,53 @@ describe("scripting route when Chrome refuses the debugger", () => { return { runtime, revisions }; } + test("the scripting outline filters before truncation and retains stable element ids under a root", async () => { + class OutlineElement { + readonly localName = "div"; + readonly isConnected = true; + readonly shadowRoot = null; + constructor(readonly role: string, readonly name: string, readonly children: OutlineElement[] = []) {} + getAttribute(name: string): string | null { + return name === "role" ? this.role : name === "aria-label" ? this.name : null; + } + hasAttribute(): boolean { return false; } + checkVisibility(): boolean { return true; } + matches(): boolean { return false; } + getRootNode(): unknown { return Reflect.get(globalThis, "document"); } + } + const root = new OutlineElement("main", "Message", [ + ...Array.from({ length: 80 }, () => new OutlineElement("button", "Archive")), + new OutlineElement("button", "More message options"), + new OutlineElement("button", "More MESSAGE options for thread"), + new OutlineElement("link", "More message options"), + ]); + const previousDocument = Reflect.get(globalThis, "document"); + const previousAgent = Reflect.get(globalThis, PAGE_AGENT_GLOBAL); + Reflect.set(globalThis, "document", { documentElement: { children: [root] }, activeElement: null }); + Reflect.deleteProperty(globalThis, PAGE_AGENT_GLOBAL); + try { + installPageAgent(); + const capture = async (query: { role?: string; name?: string }, max_nodes: number, root_id?: number) => + await runPageAgentRequest({ op: "snapshot", max_nodes, max_depth: 50, query, root_id }, PAGE_AGENT_VERSION, PAGE_AGENT_GLOBAL) as { + nodes: Array<{ id: number; role: string; name: string }>; truncated: boolean; + }; + const query = { role: "BUTTON", name: "message OPTIONS" }; + const full = await capture(query, 2); + expect(full).toMatchObject({ truncated: false, nodes: [ + { id: 1, role: "button", name: "More message options" }, + { id: 2, role: "button", name: "More MESSAGE options for thread" }, + ] }); + expect(await capture(query, 1)).toMatchObject({ nodes: [full.nodes[0]], truncated: true }); + const scoped = await capture({ role: "main" }, 1); + expect(await capture(query, 2, scoped.nodes[0]!.id)).toEqual(full); + } finally { + if (previousDocument === undefined) Reflect.deleteProperty(globalThis, "document"); + else Reflect.set(globalThis, "document", previousDocument); + if (previousAgent === undefined) Reflect.deleteProperty(globalThis, PAGE_AGENT_GLOBAL); + else Reflect.set(globalThis, PAGE_AGENT_GLOBAL, previousAgent); + } + }); + test("snapshots and acts through the page agent without re-attaching", async () => { const { runtime, revisions } = refusedRuntime(); tabStore.set(7, { id: 7, windowId: 1, groupId: -1, active: true }); @@ -6384,12 +6558,15 @@ describe("scripting route when Chrome refuses the debugger", () => { return { id: 3 }; }; const revision = await revisions.ensure(7); - const snapshot = await runtime.snapshot(TASK_A, 7, { mode: "accessibility" }); + const snapshot = await runtime.snapshot(TASK_A, 7, { mode: "accessibility", query: { role: "BUTTON", name: "next" }, max_nodes: 1 }); + expect(pageAgentRequests.filter((request) => request.op === "snapshot")).toEqual([{ + op: "snapshot", max_nodes: 1, max_depth: 50, query: { role: "BUTTON", name: "next" }, + }]); expect(snapshot).toMatchObject({ page_revision: revision, page_route: "scripting", debugger_unavailable_reason: REFUSAL, - nodes: [{ role: "button", name: "Next" }], + nodes: [{ role: "button", name: "Next", frame_id: "scripting:top" }], }); const nodes = snapshot.nodes as Array>; const ref = String(nodes[0]?.ref); diff --git a/packages/extension/test/protocol-validator.test.ts b/packages/extension/test/protocol-validator.test.ts index d27de97..5c6abdc 100644 --- a/packages/extension/test/protocol-validator.test.ts +++ b/packages/extension/test/protocol-validator.test.ts @@ -52,6 +52,29 @@ describe("native protocol reliability contracts", () => { }); }); +describe("browser_snapshot accessibility query", () => { + test("accepts optional predicates with frame and root scoping", () => { + for (const query of [{}, { role: "BUTTON" }, { name: "message" }, { role: "button", name: "More message options" }]) { + expect(parseCommand(command("browser_snapshot", { + tab_id: 1, mode: "accessibility", frame_id: "frame-1", root_ref: "rt1.root", max_nodes: 1, query, + })).params.query).toEqual(query); + } + }); + + test("rejects invalid predicates and query in every other mode", () => { + for (const query of [ + null, [], "button", 1, { role: null }, { name: null }, { role: 1 }, { name: true }, + { role: "" }, { name: "" }, { role: "x".repeat(257) }, { name: "x".repeat(2049) }, + { description: "message" }, { role: "button", extra: true }, + ]) { + expect(() => parseCommand(command("browser_snapshot", { tab_id: 1, mode: "accessibility", query }))).toThrow(); + } + for (const mode of ["text", "html", "screenshot"]) { + expect(() => parseCommand(command("browser_snapshot", { tab_id: 1, mode, query: { role: "button" } }))).toThrow(); + } + }); +}); + describe("browser_handoff challenge_cleared completion", () => { const request = (completion: unknown) => command("browser_handoff", { diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 3126880..7eaa2c9 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -164,7 +164,7 @@ export const STANDARD_TOOLS: readonly Tool[] = [ }, { name: "browser_snapshot", - description: `Read an accessibility snapshot, bounded text or HTML, or a screenshot from a task-owned tab. ${CHALLENGE_GUIDANCE.summary}`, + description: `Read an accessibility snapshot, bounded text or HTML, or a screenshot from a task-owned tab. Accessibility mode accepts query.role (exact, case-insensitive) and query.name (case-insensitive accessible-name substring, not description or value); combine them with root_ref/frame_id and max_nodes to return matching actionable refs. ${CHALLENGE_GUIDANCE.summary}`, inputSchema: schema(snapshotSchema), }, { diff --git a/packages/omp/src/index.ts b/packages/omp/src/index.ts index fe7d88b..affd196 100644 --- a/packages/omp/src/index.ts +++ b/packages/omp/src/index.ts @@ -160,7 +160,7 @@ const DEFINITIONS: ReadonlyArray<{ { name: "browser_snapshot", label: "Browser Snapshot", - description: `Read an accessibility snapshot, bounded text or HTML, or a screenshot from a task-owned tab. ${CHALLENGE_GUIDANCE.summary}`, + description: `Read an accessibility snapshot, bounded text or HTML, or a screenshot from a task-owned tab. Accessibility mode accepts query.role (exact, case-insensitive) and query.name (case-insensitive accessible-name substring, not description or value); combine them with root_ref/frame_id and max_nodes to return matching actionable refs. ${CHALLENGE_GUIDANCE.summary}`, approval: "read", schema: (z) => z.object({ tab_id: z.number().int().min(0), @@ -169,6 +169,10 @@ const DEFINITIONS: ReadonlyArray<{ root_ref: z.string().min(1).optional(), max_depth: z.number().int().min(1).max(200).optional(), max_nodes: z.number().int().min(1).max(5000).optional(), + query: z.object({ + role: z.string().min(1).max(256).optional(), + name: z.string().min(1).max(2048).optional(), + }).strict().optional(), selector: z.string().min(1).optional(), match: z.enum(["first", "last"]).optional(), full_page: z.boolean().optional(), diff --git a/packages/omp/src/pi-schema.ts b/packages/omp/src/pi-schema.ts index f18e3b1..e4b1518 100644 --- a/packages/omp/src/pi-schema.ts +++ b/packages/omp/src/pi-schema.ts @@ -41,7 +41,7 @@ const schemas: Record = { object({ mode: Type.Literal("adopt_active") }), ]), browser_snapshot: Type.Union([ - object({ tab_id: Type.Integer({ minimum: 0 }), mode: Type.Literal("accessibility"), frame_id: Type.Optional(ref()), root_ref: Type.Optional(ref()), max_depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })), max_nodes: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })) }), + object({ tab_id: Type.Integer({ minimum: 0 }), mode: Type.Literal("accessibility"), frame_id: Type.Optional(ref()), root_ref: Type.Optional(ref()), max_depth: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })), max_nodes: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })), query: Type.Optional(object({ role: Type.Optional(ref()), name: Type.Optional(selector()) })) }), object({ tab_id: Type.Integer({ minimum: 0 }), mode: stringEnum(["text", "html"]), frame_id: Type.Optional(ref()), selector: Type.Optional(selector()), match: Type.Optional(stringEnum(["first", "last"])), max_bytes: Type.Optional(Type.Integer({ minimum: 1, maximum: SNAPSHOT_TEXT_MAX_BYTES })) }), object({ tab_id: Type.Integer({ minimum: 0 }), mode: Type.Literal("screenshot"), frame_id: Type.Optional(ref()), selector: Type.Optional(selector()), full_page: Type.Optional(Type.Boolean()), format: Type.Optional(stringEnum(["png", "jpeg", "webp"])), quality: Type.Optional(Type.Integer({ minimum: 0, maximum: 100 })), max_width: Type.Optional(Type.Integer({ minimum: 1, maximum: SCREENSHOT_MAX_DIMENSION })), max_height: Type.Optional(Type.Integer({ minimum: 1, maximum: SCREENSHOT_MAX_DIMENSION })), max_bytes: Type.Optional(Type.Integer({ minimum: 1, maximum: SCREENSHOT_MAX_BYTES })) }), ]), diff --git a/packages/omp/test/extension.test.ts b/packages/omp/test/extension.test.ts index d9c6ead..f3bfe5f 100644 --- a/packages/omp/test/extension.test.ts +++ b/packages/omp/test/extension.test.ts @@ -1111,6 +1111,21 @@ test("OMP tools expose compact and expanded custom renderers", () => { } }); +test("Pi accessibility queries validate predicates only in accessibility mode", () => { + const schema = piSchema("browser_snapshot") as Parameters[0]; + for (const query of [{}, { role: "button" }, { name: "options" }, { role: "BUTTON", name: "message" }]) { + expect(Value.Check(schema, { + tab_id: 1, mode: "accessibility", query, root_ref: "rt1.root", frame_id: "child", max_nodes: 1, + })).toBe(true); + } + for (const query of [null, [], "button", { role: 1 }, { name: "" }, { role: "x".repeat(257) }, { name: "x".repeat(2049) }, { description: "message" }]) { + expect(Value.Check(schema, { tab_id: 1, mode: "accessibility", query })).toBe(false); + } + for (const mode of ["text", "html", "screenshot"]) { + expect(Value.Check(schema, { tab_id: 1, mode, query: { role: "button" } })).toBe(false); + } +}); + test("Pi mode registers the same tools with TypeBox schemas and Pi metadata", () => { const tools = register(false, "pi").tools; expect(tools.map((tool) => tool.name)).toEqual([ diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index cebed1f..fd3f79d 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -56,7 +56,7 @@ type Target = ({ ref: string; selector?: never } | { ref?: never; selector: stri type DownloadTarget = Target & { expect_download?: boolean }; export type BrowserSnapshotParams = - | ({ tab_id: number; mode: "accessibility"; root_ref?: string; max_depth?: number; max_nodes?: number } & FrameScoped) + | ({ tab_id: number; mode: "accessibility"; root_ref?: string; max_depth?: number; max_nodes?: number; query?: { role?: string; name?: string } } & FrameScoped) | ({ tab_id: number; mode: "text" | "html"; selector?: string; match?: "first" | "last"; max_bytes?: number } & FrameScoped) | ({ tab_id: number; diff --git a/schemas/rpc/v1/browser-snapshot.schema.json b/schemas/rpc/v1/browser-snapshot.schema.json index 82c8d48..33169dc 100644 --- a/schemas/rpc/v1/browser-snapshot.schema.json +++ b/schemas/rpc/v1/browser-snapshot.schema.json @@ -12,7 +12,16 @@ "frame_id": { "type": "string", "minLength": 1, "maxLength": 256 }, "root_ref": { "type": "string", "minLength": 1, "maxLength": 256 }, "max_depth": { "type": "integer", "minimum": 1, "maximum": 200 }, - "max_nodes": { "type": "integer", "minimum": 1, "maximum": 5000 } + "max_nodes": { "type": "integer", "minimum": 1, "maximum": 5000 }, + "query": { + "type": "object", + "description": "Accessibility only. Combine exact case-insensitive role and case-insensitive accessible-name substring filters; description and value are not searched. max_nodes limits matches.", + "properties": { + "role": { "type": "string", "minLength": 1, "maxLength": 256 }, + "name": { "type": "string", "minLength": 1, "maxLength": 2048 } + }, + "additionalProperties": false + } }, "additionalProperties": false }, diff --git a/tests/architecture/verify_protocol_schemas.py b/tests/architecture/verify_protocol_schemas.py index c7d956b..9e0886b 100755 --- a/tests/architecture/verify_protocol_schemas.py +++ b/tests/architecture/verify_protocol_schemas.py @@ -80,7 +80,7 @@ def verify_core_messages(schemas: dict[Path, dict], registry: Registry) -> int: core_request("browser_open", {"mode": "create", "background": True}, mutation=True), core_request( "browser_snapshot", - {"tab_id": 1, "mode": "accessibility", "max_nodes": 100}, + {"tab_id": 1, "mode": "accessibility", "max_nodes": 100, "query": {"role": "button", "name": "options"}}, mutation=False, ), core_request( @@ -283,6 +283,22 @@ def verify_core_messages(schemas: dict[Path, dict], registry: Registry) -> int: mutation=False, ) expect_invalid(request_validator, invalid_snapshot_match, "snapshot match without selector") + for query in [ + None, [], "button", {"role": None}, {"name": True}, {"role": ""}, + {"name": ""}, {"role": "x" * 257}, {"name": "x" * 2049}, + {"description": "message"}, + ]: + expect_invalid( + request_validator, + core_request("browser_snapshot", {"tab_id": 1, "mode": "accessibility", "query": query}, mutation=False), + "invalid accessibility query", + ) + for mode in ["text", "html", "screenshot"]: + expect_invalid( + request_validator, + core_request("browser_snapshot", {"tab_id": 1, "mode": mode, "query": {"role": "button"}}, mutation=False), + "accessibility query in another mode", + ) ambiguous_upload_target = core_request( "browser_act", { From 0ea074086523340d1cedc3844c8c2a78a705339c Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:22:32 -0700 Subject: [PATCH 2/3] Keep the scripting route's placeholder frame out of snapshot nodes --- packages/extension/src/browser.ts | 1 - packages/extension/test/extension.test.ts | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/src/browser.ts b/packages/extension/src/browser.ts index f7b114d..db96d87 100644 --- a/packages/extension/src/browser.ts +++ b/packages/extension/src/browser.ts @@ -1093,7 +1093,6 @@ export class StandardBrowserRuntime { taskId, route: "scripting", }), - frame_id: SCRIPTING_FRAME_ID, } : {}), ...node, diff --git a/packages/extension/test/extension.test.ts b/packages/extension/test/extension.test.ts index 2654bf5..c2afcfd 100644 --- a/packages/extension/test/extension.test.ts +++ b/packages/extension/test/extension.test.ts @@ -6566,10 +6566,11 @@ describe("scripting route when Chrome refuses the debugger", () => { page_revision: revision, page_route: "scripting", debugger_unavailable_reason: REFUSAL, - nodes: [{ role: "button", name: "Next", frame_id: "scripting:top" }], + nodes: [{ role: "button", name: "Next" }], }); const nodes = snapshot.nodes as Array>; const ref = String(nodes[0]?.ref); + expect(nodes[0]).not.toHaveProperty("frame_id"); const acted = await runtime.act(TASK_A, 7, revision, [{ kind: "click", ref }]); expect(acted.result?.actions).toEqual([{ kind: "click", completed: true, page_route: "scripting" }]); expect(pageAgentRequests.filter((request) => request.op === "act")).toEqual([{ op: "act", id: 3, kind: "click" }]); From 75b67206153d329544a06bf610f5b9b960dfde88 Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:33:29 -0700 Subject: [PATCH 3/3] Format the snapshot query protocol types --- host-rs/crates/agenttab-protocol/src/lib.rs | 41 +++++++++++---------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/host-rs/crates/agenttab-protocol/src/lib.rs b/host-rs/crates/agenttab-protocol/src/lib.rs index 0ff8c64..862001a 100644 --- a/host-rs/crates/agenttab-protocol/src/lib.rs +++ b/host-rs/crates/agenttab-protocol/src/lib.rs @@ -432,7 +432,9 @@ pub struct AccessibilityQuery { } // Serde's derived struct visitor also accepts sequences; the wire query must be an object. -fn deserialize_accessibility_query<'de, D>(deserializer: D) -> Result, D::Error> +fn deserialize_accessibility_query<'de, D>( + deserializer: D, +) -> Result, D::Error> where D: serde::Deserializer<'de>, { @@ -467,7 +469,11 @@ pub enum BrowserSnapshotParams { max_depth: Option, #[serde(default, skip_serializing_if = "Option::is_none")] max_nodes: Option, - #[serde(default, deserialize_with = "deserialize_accessibility_query", skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "deserialize_accessibility_query", + skip_serializing_if = "Option::is_none" + )] query: Option, }, Text { @@ -3021,7 +3027,8 @@ mod tests { "tab_id": 1, "mode": "accessibility", "frame_id": "frame-1", "root_ref": "rt1.root", "max_nodes": 1, "query": query, }); - let (_, parsed) = RpcRequest::parse(request("browser_snapshot", params.clone(), false)).unwrap(); + let (_, parsed) = + RpcRequest::parse(request("browser_snapshot", params.clone(), false)).unwrap(); assert_eq!(parsed.value(), params); } for query in [ @@ -3041,24 +3048,20 @@ mod tests { json!({"description": "message"}), json!({"role": "button", "extra": true}), ] { - assert!( - RpcRequest::parse(request( - "browser_snapshot", - json!({"tab_id": 1, "mode": "accessibility", "query": query}), - false, - )) - .is_err() - ); + assert!(RpcRequest::parse(request( + "browser_snapshot", + json!({"tab_id": 1, "mode": "accessibility", "query": query}), + false, + )) + .is_err()); } for mode in ["text", "html", "screenshot"] { - assert!( - RpcRequest::parse(request( - "browser_snapshot", - json!({"tab_id": 1, "mode": mode, "query": {"role": "button"}}), - false, - )) - .is_err() - ); + assert!(RpcRequest::parse(request( + "browser_snapshot", + json!({"tab_id": 1, "mode": mode, "query": {"role": "button"}}), + false, + )) + .is_err()); } }