diff --git a/capability/devicecontrol.capability-index.json b/capability/devicecontrol.capability-index.json new file mode 100644 index 00000000..76500fc4 --- /dev/null +++ b/capability/devicecontrol.capability-index.json @@ -0,0 +1,2227 @@ +{ + "schema_version": 1, + "version": "1.0", + "build_id": "8445466_2026-10-19T06:53:46Z", + "devicecontrol": { + "title": "Device Control", + "summary": "Device control: drive a real desktop or mobile browser page by page. The account side lists every operating system, browser and version combination available, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link, video, and console, network and profiling logs \u2014 after the browser is released. The session side opens a browser on a chosen combination and drives it: navigate, go back and forward, reload, find elements by CSS or XPath, click, type, clear, screenshot the viewport, resize the window, and move between tabs and iframes. Web pages are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Mobile APPS are not here: those are mobile device control, which drives an installed app rather than a browser. Scrolling is done with scroll_to_element or scroll_page, there is no capability that waits, and element ids go stale as soon as the page navigates or re-renders. A session holds a browser until it is ended.", + "routing_terms": [ + "automate", + "browser", + "web", + "website", + "web page", + "chrome", + "firefox", + "safari", + "edge", + "desktop browser", + "mobile browser", + "selenium", + "webdriver", + "navigate", + "url", + "click", + "type", + "scroll", + "iframe", + "tab", + "screenshot", + "console logs", + "network logs", + "har", + "browser session", + "cross browser", + "device control", + "browser control" + ], + "base_url": "https://hub.browserstack.com", + "hosts": { + "api": "https://api.browserstack.com" + }, + "auth": { + "type": "http", + "scheme": "basic" + }, + "capabilities": [ + { + "name": "list_browsers", + "method": "GET", + "path": "/automate/browsers.json", + "mode": "read", + "entity": "browser", + "intent": "List every operating system, browser and version combination this account can run a web session on, with the exact names create_session expects.", + "guidance": [ + "The endpoint accepts no filtering on the wire \u2014 every query parameter is ignored, and a filtered call comes back byte-identical to the bare one \u2014 so pass `select` instead and never fetch this whole. The full answer is several thousand rows; `select` with `distinct` turns the usual question into a few hundred bytes.", + "To ask what values EXIST, use distinct rather than reading rows: distinct ['browser'] names every browser, distinct ['os','os_version'] every platform, distinct ['device'] every mobile device. To check one combination, put it in `where` and see whether anything comes back.", + "A desktop row carries os, os_version, browser and browser_version, with device null. A mobile row carries device and real_mobile true instead, and its browser_version is null because the browser ships with the OS.", + "os is spelled Windows, OS X, android and ios \u2014 two capitalised and two not, in the same column \u2014 and `where` matches case-insensitively, so either spelling finds them. Browser names are lower case apart from chromeForTesting.", + "Rows come back in the product's own order, which is OLDEST VERSION FIRST, and `limit` takes the first N of them \u2014 so a limit is the wrong way to find the newest build and will hand back Chrome 37 for Windows 11. Ask for the versions with distinct and no limit (a hundred-odd strings, under 3 KB) and read the end of the list.", + "Pass the strings into create_session exactly as they appear here; an unlisted combination is refused rather than substituted." + ], + "returns": [ + "browser", + "browser_version", + "os", + "os_version", + "device", + "real_mobile" + ], + "responses": { + "200": { + "description": "Every supported combination.", + "schema": { + "$schema": "BrowserList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api", + "narrowable": { + "fields": [ + "os", + "os_version", + "browser", + "browser_version", + "device", + "real_mobile" + ], + "note": "The endpoint takes no parameters of its own, so this narrowing is applied to the response after the call." + } + }, + { + "name": "get_plan", + "method": "GET", + "path": "/automate/plan.json", + "mode": "read", + "entity": "plan", + "intent": "Show the browser-session plan and how many parallel browser sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", + "guidance": [ + "A start past the limit queues rather than failing, which looks like a slow start; this is how to tell the two apart.", + "The counts are account-wide and include sessions other people and CI started.", + "This is the web plan. Mobile device sessions have their own plan, counted separately." + ], + "returns": [ + "automate_plan", + "parallel_sessions_running", + "parallel_sessions_max_allowed", + "team_parallel_sessions_max_allowed", + "queued_sessions", + "queued_sessions_max_allowed", + "terminal_access" + ], + "responses": { + "200": { + "description": "Plan name and parallel usage.", + "schema": { + "$schema": "Plan" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_session", + "method": "GET", + "path": "/automate/sessions/{session_id}.json", + "mode": "read", + "entity": "browser_session", + "intent": "Get a web session's record: status, browser, duration, the shareable link, the video, and the console, network and profiling logs \u2014 use this for the links to share or to review a finished run.", + "guidance": [ + "public_url opens without a BrowserStack login, so it is the link to share outside the team; it carries an auth token and should be treated as a secret.", + "browser_url is the session on the dashboard and needs a login.", + "video_url is only complete once the session has ended.", + "har_logs_url, browser_console_logs_url and browser_profiling_url are present only when the session was started with the matching capability; they are fetched separately and are not part of this answer.", + "Works for any session on the account, including ones this caller did not start." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "status", + "reason", + "browser", + "browser_version", + "os", + "os_version", + "device", + "duration", + "public_url", + "browser_url", + "video_url", + "har_logs_url", + "browser_console_logs_url", + "browser_profiling_url", + "build_name", + "project_name", + "created_at", + "browserstack_status", + "hashed_id", + "name" + ], + "responses": { + "200": { + "description": "The session record.", + "schema": { + "$schema": "SessionDetails" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "set_session_status", + "method": "PUT", + "path": "/automate/sessions/{session_id}.json", + "mode": "write", + "entity": "browser_session", + "intent": "Mark a web session passed or failed with a reason, so the dashboard shows the outcome.", + "guidance": [ + "Works after the session has ended as well as during it.", + "Only 'passed' and 'failed' are accepted.", + "reason is truncated at 255 characters.", + "A session left unmarked reads as an abandoned run on the dashboard." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "status", + "type": "string", + "required": true, + "values": [ + "passed", + "failed" + ], + "example": "passed" + }, + { + "name": "reason", + "type": "string", + "maxLength": 255, + "description": "Short explanation shown beside the status.", + "example": "The page behaved as expected" + } + ], + "returns": [ + "status", + "reason", + "hashed_id", + "name", + "browser", + "os", + "os_version", + "build_name", + "project_name" + ], + "responses": { + "200": { + "description": "The updated session record.", + "schema": { + "$schema": "SessionStatusUpdate" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "create_session", + "method": "POST", + "path": "/wd/hub/session", + "mode": "write", + "timeout_seconds": 180, + "entity": "browser_session", + "intent": "Start a browser session on a real desktop or mobile browser and get a session id to drive it with \u2014 use this to open a page and interact with it step by step.", + "guidance": { + "shared": [ + "A DESKTOP target needs browser_name with os and os_version; a MOBILE BROWSER needs browser_name with device_name and real_mobile instead, and takes no browser_version because the browser ships with the OS. Sending both kinds together is refused.", + "ON MOBILE THE BROWSER IS NAMED AFTER THE PLATFORM, not the product: Chrome on an Android phone is \"android\", Safari on an iPhone is \"iphone\" and on an iPad \"ipad\". Samsung Internet is \"samsung\" and Chromium-based mobile browsers are \"chromium\". Asking for \"chrome\" or \"safari\" on a phone is refused.", + "Take every value from list_browsers exactly as it spells them; an unlisted combination is refused rather than substituted.", + "The session id comes back at value.sessionId \u2014 carry it into every other browser_session capability.", + "A session starts in seconds rather than the tens of seconds a device session takes, but it still holds a browser and consumes browser minutes until it ends: always finish with set_session_status and then delete_session.", + "The session ends itself after idle_timeout seconds without a command, and after 2 hours in total.", + "console_logs and network_logs must be asked for here; they cannot be turned on once the session is running, and get_session only links to them when they were.", + "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", + "resolution takes one of a fixed set of values per operating system, not any width and height: an unsupported pair is refused outright rather than rounded to the nearest one. The browser window is well short of the screen it sits on \u2014 1920x1080 gives a viewport around 930x860 \u2014 so measure it rather than assuming the request was honoured." + ], + "local": [ + "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the session dashboard \u2014 it needs a BrowserStack login in that browser." + ], + "remote": [ + "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." + ] + }, + "body": [ + { + "name": "browser_name", + "type": "string", + "required": true, + "json_path": "/capabilities/alwaysMatch/browserName", + "values": [ + "chrome", + "chromeForTesting", + "edge", + "firefox", + "ie", + "opera", + "safari", + "android", + "chromium", + "ipad", + "iphone", + "samsung" + ], + "description": "Lower case except chromeForTesting, exactly as list_browsers spells it. Desktop: chrome, chromeForTesting, edge, firefox, ie, opera, safari. Mobile: android, chromium, ipad, iphone, samsung.", + "example": "chrome" + }, + { + "name": "browser_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/browserVersion", + "description": "Desktop only. Omit for the newest, or for a mobile browser, which has no separate version.", + "example": "latest" + }, + { + "name": "os", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/os", + "description": "Desktop only, paired with os_version. Only Windows and OS X; a mobile browser uses device_name instead.", + "example": "Windows", + "values": [ + "Windows", + "OS X" + ] + }, + { + "name": "os_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/osVersion", + "description": "The desktop OS version, or the mobile device's OS version.", + "example": "11" + }, + { + "name": "device_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/deviceName", + "description": "Mobile browser only, paired with real_mobile and os_version.", + "example": "Samsung Galaxy S23" + }, + { + "name": "real_mobile", + "type": "string", + "values": [ + "true" + ], + "json_path": "/capabilities/alwaysMatch/bstack:options/realMobile", + "description": "Mobile browser only: the string \"true\", not a boolean.", + "example": "true" + }, + { + "name": "resolution", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/resolution", + "description": "Desktop screen resolution, from a fixed set the chosen OS supports \u2014 an unsupported value is refused with \"The specified screen resolution is not compatible with the chosen OS version\" and no session starts. 1920x1080 is accepted on Windows 11; 2560x1440 is not. The browser window is much smaller than the screen either way, so read the real size rather than assuming it.", + "example": "1920x1080" + }, + { + "name": "idle_timeout", + "type": "integer", + "minimum": 0, + "maximum": 300, + "default": 90, + "json_path": "/capabilities/alwaysMatch/bstack:options/idleTimeout", + "description": "Seconds the session may sit without a command before it is ended. Raise it when an agent drives the session step by step.", + "example": 300 + }, + { + "name": "video", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/video", + "description": "Record a video of the session. On by default; set false for a page showing sensitive data.", + "example": true + }, + { + "name": "console_logs", + "type": "string", + "values": [ + "disable", + "errors", + "warnings", + "info", + "verbose" + ], + "json_path": "/capabilities/alwaysMatch/bstack:options/consoleLogs", + "description": "Capture the browser console at this level.", + "example": "info" + }, + { + "name": "network_logs", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/networkLogs", + "description": "Capture network traffic as a HAR, linked from get_session.", + "example": true + }, + { + "name": "project_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/projectName", + "description": "Groups sessions on the dashboard.", + "example": "Web checkout" + }, + { + "name": "build_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/buildName", + "description": "Build the session is filed under.", + "example": "nightly" + }, + { + "name": "session_name", + "type": "string", + "maxLength": 255, + "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName", + "description": "Name shown for this session.", + "example": "sign in and add to basket" + } + ], + "returns": [ + "value.sessionId", + "value.capabilities" + ], + "responses": { + "200": { + "description": "The new session.", + "schema": { + "$schema": "NewSessionResponse" + } + }, + "400": { + "$response": "WebDriverError" + } + } + }, + { + "name": "navigate_to", + "method": "POST", + "path": "/wd/hub/session/{session_id}/url", + "mode": "write", + "entity": "browser_session", + "intent": "Open a URL in the session's browser \u2014 use this to go to the page you want to work on.", + "guidance": [ + "The call returns once the page has loaded, so there is no need to wait afterwards; a slow page is bounded by the page-load timeout rather than by this call.", + "A URL without a scheme is rejected: send the https:// prefix.", + "Navigating away invalidates every element id already held \u2014 find elements again on the new page." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "url", + "type": "string", + "required": true, + "format": "uri", + "description": "Absolute URL including the scheme.", + "example": "https://www.example.com/products" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_current_url", + "method": "GET", + "path": "/wd/hub/session/{session_id}/url", + "mode": "read", + "entity": "browser_session", + "intent": "Read the URL the browser is on now \u2014 use this to confirm a navigation, a redirect or a form submission went where expected.", + "guidance": [ + "A single-page app can change what is on screen without changing this, so confirm from the page rather than the URL alone." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The current URL.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_title", + "method": "GET", + "path": "/wd/hub/session/{session_id}/title", + "mode": "read", + "entity": "browser_session", + "intent": "Read the page title \u2014 the cheapest way to confirm which page is loaded.", + "guidance": [ + "The cheapest way to confirm which page is showing, and enough on its own for most navigation checks." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The document title.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "go_back", + "method": "POST", + "path": "/wd/hub/session/{session_id}/back", + "mode": "write", + "entity": "browser_session", + "intent": "Go back one entry in the browser's history.", + "guidance": [ + "Takes no parameters of its own.", + "Element ids from the page being left stop working; find them again.", + "Going back past the first page of the session does nothing and still answers 200." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "go_forward", + "method": "POST", + "path": "/wd/hub/session/{session_id}/forward", + "mode": "write", + "entity": "browser_session", + "intent": "Go forward one entry in the browser's history, after going back.", + "guidance": [ + "Takes no parameters of its own.", + "Does nothing when there is nothing to go forward to, and still answers 200." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "refresh_page", + "method": "POST", + "path": "/wd/hub/session/{session_id}/refresh", + "mode": "write", + "entity": "browser_session", + "intent": "Reload the current page \u2014 use this to pick up a change the page does not reflect on its own.", + "guidance": [ + "Takes no parameters of its own.", + "Every element id held becomes stale; find them again.", + "Anything typed and not submitted is lost." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "get_page_source", + "method": "GET", + "path": "/wd/hub/session/{session_id}/source", + "mode": "read", + "entity": "browser_session", + "intent": "Get the current page's HTML \u2014 use this to find selectors, or to read content a screenshot cannot show.", + "guidance": [ + "A real page runs to tens of kilobytes or more, so search it for what is needed rather than reading all of it. get_title answers most questions far more cheaply.", + "This is the page as the browser has it now, including anything scripts have changed, not the HTML the server sent.", + "Inside a frame this returns that frame's document; switch_to_frame with a null id first to get the top-level page back." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The page HTML.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "find_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element", + "mode": "read", + "entity": "browser_session", + "intent": "Locate one element on the page by CSS selector, XPath or link text \u2014 use this to get the element id that click_element, send_keys and the element readers act on.", + "guidance": [ + "The element id comes back under value[\"element-6066-11e4-a52e-4f735466cecf\"]; pass that string as element_id.", + "No match is a 404 with error \"no such element\". The element may not have rendered yet, or may be inside a frame \u2014 switch_to_frame first in that case.", + "A css selector is the cheapest and most stable choice; xpath is slower and brittle to markup changes.", + "An id goes stale the moment the page navigates, reloads or re-renders that part of the DOM, and using a stale one answers \"stale element reference\". Find it again rather than holding it across a navigation." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "css selector", + "xpath", + "link text", + "partial link text", + "tag name" + ], + "example": "css selector" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": "#add-to-cart" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The matching element reference.", + "schema": { + "$schema": "ElementResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "find_elements", + "method": "POST", + "path": "/wd/hub/session/{session_id}/elements", + "mode": "read", + "entity": "browser_session", + "intent": "Locate every element matching a selector \u2014 use this to count matches, or to work through a list of results, products or rows.", + "guidance": [ + "No match is an EMPTY LIST and a 200, not a 404 \u2014 the opposite of find_element, which is how to test whether something is absent without handling an error.", + "The order is the document's own.", + "Each entry carries the same element-6066 key as find_element." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "css selector", + "xpath", + "link text", + "partial link text", + "tag name" + ], + "example": "css selector" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": ".product-card" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Every matching element, possibly none.", + "schema": { + "$schema": "ElementListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "click_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/click", + "mode": "write", + "entity": "browser_session", + "intent": "Click an element \u2014 use this for buttons, links, checkboxes and anything else that responds to a click.", + "guidance": [ + "Takes no parameters of its own.", + "The element must be in view and not covered. A click on something another element overlaps answers \"element click intercepted\" and names the element in the way \u2014 scroll it into view with scroll_to_element, or dismiss the overlay first.", + "An element that is not visible answers \"element not interactable\".", + "Success means the click was dispatched, not that the page has finished reacting: read the page or the URL afterwards to confirm." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "send_keys", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/value", + "mode": "write", + "entity": "browser_session", + "intent": "Type into a field \u2014 use this to fill a search box, a login form or any editable input.", + "guidance": [ + "APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.", + "Read the result back with get_element_property on \"value\": a field's current contents are a property, not text. On Safari for iOS, where property reads answer 405, use get_element_attribute with name value instead.", + "A \\n at the end submits the form on a desktop browser and on Chrome for Android, and is quicker than finding the submit button. IT DOES NOT SUBMIT ON SAFARI FOR iOS \u2014 the text lands in the field and the page stays where it is, whether the newline is sent with the text or on its own. There, click the form's submit button with click_element \u2014 scroll_to_element first if it sits below the fold.", + "The element must be editable; typing into anything else answers \"element not interactable\".", + "A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" + } + ], + "body": [ + { + "name": "text", + "type": "string", + "required": true, + "description": "The characters to type. End with \\n to submit.", + "example": "hello" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "clear_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/clear", + "mode": "write", + "entity": "browser_session", + "intent": "Empty an input \u2014 use this before typing, since send_keys adds to what is already there.", + "guidance": [ + "WORKS ON EVERY BROWSER: desktop Chrome, Chrome for Android and Safari on iPhone all answer 200 and leave the field empty; typing afterwards starts from nothing.", + "Takes no parameters of its own.", + "Confirm with get_element_property on \"value\", which reads empty afterwards \u2014 on Safari for iOS, where property reads answer 405, use get_element_attribute with name value instead. Measured empty after clearing on desktop Chrome, Chrome for Android and Safari on iPhone.", + "Clearing also fires the page's own change handlers, so a form may revalidate and show an error about the now-empty field.", + "The element must be editable; anything else answers \"invalid element state\"." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "get_element_text", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/text", + "mode": "read", + "entity": "browser_session", + "intent": "Read the visible text of an element \u2014 use this to check a heading, a price, a label or an error message.", + "guidance": [ + "EMPTY FOR AN INPUT. What a user typed is a property, not text: read it with get_element_property on \"value\".", + "This is the rendered text, so it excludes anything hidden by CSS and collapses whitespace the way the page displays it.", + "For a container it returns the text of everything inside it, which can be far more than expected." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The element's visible text.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_property", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/property/{name}", + "mode": "read", + "entity": "browser_session", + "intent": "Read a live DOM property of an element \u2014 use this for what a field currently holds, or whether a box is checked right now.", + "guidance": [ + "NOT ON SAFARI FOR iOS: the driver there answers 405 \"unknown method\" to every property read. On an iPhone or iPad read get_element_attribute with name value instead, which returns what the field holds right now; on desktop browsers and Chrome for Android this is the live view and the attribute is the markup.", + "THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE. The \"value\" attribute is the markup's initial value rather than what is in the box now, and an input carries no text.", + "Useful names: value, checked, selected, disabled, innerText, href.", + "A property the element does not have comes back null, not an error." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "The DOM property to read.", + "example": "value" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The property's value, or null.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_attribute", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/attribute/{name}", + "mode": "read", + "entity": "browser_session", + "intent": "Read an HTML attribute of an element \u2014 use this for ids, classes, data- attributes and link targets as the markup declares them.", + "guidance": [ + "On desktop browsers and Chrome for Android this is the attribute as written in the HTML, which is NOT what a field currently holds: for that, use get_element_property on \"value\". On Safari for iOS it is the other way round \u2014 get_element_property answers 405 there, and this call with name value returns what the field holds right now.", + "An attribute the element does not carry comes back null rather than an error, so null means absent, not empty.", + "An attribute a script has changed may still read as the original markup; the property is the live view." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "The attribute to read.", + "example": "href" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The attribute's value, or null when absent.", + "schema": { + "$schema": "NullableStringResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "is_element_displayed", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/displayed", + "mode": "read", + "entity": "browser_session", + "intent": "Check whether an element is actually visible on the page \u2014 use this before clicking, or to confirm something appeared or went away.", + "guidance": [ + "False for an element that exists in the DOM but is hidden, zero-sized or has no visible text, which is why find_element succeeding does not mean a click will.", + "It does not say whether the element is scrolled into view or covered by something else; a click can still be intercepted." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "f.00000000000000000000000000000000.d.0000000000000000.e.1" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when the element is visible.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "scroll_to_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "browser_session", + "intent": "Bring an element into view \u2014 use this before clicking or screenshotting something below the fold, since both fail or miss when the target is off screen.", + "guidance": [ + "THE RELIABLE FORM ON EVERY PLATFORM. Prefer this to scrolling the page by a distance: it works out where the element is for you, and it does not depend on the page reporting its height correctly, which scroll_page does.", + "The element_id goes in args as {\"element-6066-11e4-a52e-4f735466cecf\": \"\"} and arrives in the script as arguments[0]; find_element gives you the id.", + "Returns the element's position after scrolling, so a caller can confirm it really is in view rather than trusting the status.", + "A click on a covered element answers \"element click intercepted\" \u2014 scroll it into view with this first." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "example": "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};", + "values": [ + "arguments[0].scrollIntoView({block:'center'}); var r=arguments[0].getBoundingClientRect(); return {top:r.top,bottom:r.bottom,innerHeight:window.innerHeight};" + ] + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object holding the element reference.", + "example": [ + { + "element-6066-11e4-a52e-4f735466cecf": "" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Whatever the script returned.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "scroll_page", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "browser_session", + "intent": "Scroll the page itself by a distance or to the bottom \u2014 use this when there is no element to aim at, such as reaching a footer or loading more of an infinite list.", + "guidance": [ + "PREFER scroll_to_element when you have an element: it works out where the target is for you. Use this form only when there is nothing to aim at.", + "Choose what it does with args: \"bottom\", \"top\", or a pixel count.", + "Returns the scroll position and the page height, so a caller can tell whether it actually reached the end: at rest at the bottom, scrollY + innerHeight equals scrollHeight within a couple of pixels \u2014 a scaled phone screen reports fractional positions, so do not demand exact equality. ON SAFARI THE POSITION IN THE \"bottom\" CALL'S OWN RESPONSE IS WHERE IT WAS ASKED TO GO, NOT WHERE IT STOPPED \u2014 it comes back equal to scrollHeight, which no resting position can be. Call again with args 0 to read the real position; that second reading is the one to check." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "example": "var a=arguments[0]; var h=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight); if(a==='bottom'){window.scrollTo(0,h);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)};", + "values": [ + "var a=arguments[0]; var h=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight); if(a==='bottom'){window.scrollTo(0,h);} else if(a==='top'){window.scrollTo(0,0);} else {window.scrollBy(0,Number(a));} return {scrollY:window.scrollY, innerHeight:window.innerHeight, scrollHeight:Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)};" + ] + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one value: \"bottom\", \"top\", or a number of pixels to scroll by (negative scrolls up).", + "example": [ + "bottom" + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Whatever the script returned.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "execute_script", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "browser_session", + "intent": "Run JavaScript in the page \u2014 use this to scroll, read something the element readers cannot reach, or drive a control that resists clicking.", + "guidance": [ + "SCROLLING LIVES HERE, and the reliable form is the ELEMENT one: pass the element in args and call arguments[0].scrollIntoView({block:'center'}). window.scrollTo(0, document.body.scrollHeight) reaches the bottom on a desktop browser, but on Chrome for Android it can stop short and leave a footer off screen while still reporting success \u2014 so scroll the thing you actually want rather than the page, and confirm with a screenshot or the page source.", + "READING THE PAGE'S MARKUP LIVES HERE TOO: 'return document.documentElement.outerHTML' for the whole document, or 'return arguments[0].outerHTML' with an element in args for one part of it. Prefer the element form \u2014 a real page runs to tens of kilobytes and the whole document is rarely what is wanted. This is also how to discover a selector on a page you have not seen: read the markup around the area you care about rather than guessing a selector, which fails silently when it matches nothing.", + "`return` is required to get a value back; without it the answer is null.", + "An element id can be passed in args as {\"element-6066-11e4-a52e-4f735466cecf\": \"\"} and arrives as a real element in arguments.", + "It runs with the page's own privileges and can change anything, so a script that alters state is as consequential as a click.", + "A script that throws answers \"javascript error\" with the page's own message." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The JavaScript to run. Use `return` to get a value back.", + "example": "window.scrollTo(0, document.body.scrollHeight); return window.scrollY" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Arguments, available as `arguments` in the script. Send an empty list when there are none.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Whatever the script returned.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "take_screenshot", + "method": "GET", + "path": "/wd/hub/session/{session_id}/screenshot", + "mode": "read", + "entity": "browser_session", + "intent": "Take a screenshot of the browser's viewport, as a base64 PNG \u2014 use this to see the page as a user would.", + "guidance": [ + "Returned inline as base64 and typically tens of kilobytes, so take one when the picture is needed rather than after every step.", + "The VIEWPORT only, not the whole page: anything below the fold needs scrolling into view first with scroll_to_element.", + "get_title and get_element_property answer most checks far more cheaply than reading an image." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Base64-encoded PNG of the viewport.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_window_rect", + "method": "GET", + "path": "/wd/hub/session/{session_id}/window/rect", + "mode": "read", + "entity": "browser_session", + "intent": "Read the browser window's size and position \u2014 use this to learn the real viewport before planning anything that depends on layout.", + "guidance": [ + "The window is smaller than the screen resolution asked for at session start, because of the browser's own chrome; this is the real figure." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "The window rectangle.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_window_rect", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window/rect", + "mode": "write", + "entity": "browser_session", + "intent": "Resize or move the browser window \u2014 use this to check a layout at a particular width.", + "guidance": [ + "The response is the rectangle actually applied, which can differ from the one asked for: a window cannot exceed the screen, and some sizes are adjusted.", + "Resizing re-lays out the page, so element positions change and anything measured before is stale.", + "A real mobile browser is not uniformly resizable: an Android device accepts the call, while iOS refuses it with a 405. Read the size back from get_window_rect rather than assuming either." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "width", + "type": "integer", + "minimum": 0, + "example": 1200 + }, + { + "name": "height", + "type": "integer", + "minimum": 0, + "example": 900 + }, + { + "name": "x", + "type": "integer", + "example": 0 + }, + { + "name": "y", + "type": "integer", + "example": 0 + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "The rectangle now in effect.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "maximize_window", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window/maximize", + "mode": "write", + "entity": "browser_session", + "intent": "Maximise the browser window \u2014 use this to give the page the most room before reading a wide layout.", + "guidance": [ + "Takes no parameters of its own.", + "Page layout changes, so anything measured before is stale.", + "Aimed at a desktop browser; a real mobile browser is already full screen and may refuse it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "The rectangle after maximising.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true, + "disabled": true + }, + { + "name": "list_window_handles", + "method": "GET", + "path": "/wd/hub/session/{session_id}/window/handles", + "mode": "read", + "entity": "browser_session", + "intent": "List the open tabs and windows \u2014 use this to find a tab a link opened, before switching to it.", + "guidance": [ + "The first handle is the one the session started in. A new tab appears at the end.", + "A handle is opaque and only valid within this session.", + "Commands keep going to the tab last switched to, even after a link opens another one \u2014 a click that opens a tab does not move the session into it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Every open tab or window.", + "schema": { + "$schema": "StringListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "switch_to_window", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window", + "mode": "write", + "entity": "browser_session", + "intent": "Point the session at a different tab or window \u2014 use this after a link opens one, so later commands act on it.", + "guidance": [ + "Use a handle exactly as list_window_handles returned it; an invented one answers \"no such window\".", + "Element ids from the previous tab do not carry over.", + "Switching does not close anything; the other tab stays open." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "handle", + "type": "string", + "required": true, + "description": "Handle from list_window_handles.", + "example": "00000000000000000000000000000000" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "open_new_window", + "method": "POST", + "path": "/wd/hub/session/{session_id}/window/new", + "mode": "write", + "entity": "browser_session", + "intent": "Open a new tab or window \u2014 use this to work on two pages at once.", + "guidance": [ + "The new handle comes back in the response, but the session does NOT move into it: call switch_to_window next.", + "The new tab starts blank; navigate_to after switching." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "type", + "type": "string", + "values": [ + "tab", + "window" + ], + "example": "tab", + "description": "Open a tab or a separate window." + } + ], + "returns": [ + "value.handle", + "value.type" + ], + "responses": { + "200": { + "description": "The new tab's handle.", + "schema": { + "$schema": "NewWindowResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "switch_to_frame", + "method": "POST", + "path": "/wd/hub/session/{session_id}/frame", + "mode": "write", + "entity": "browser_session", + "intent": "Move into an iframe, or back out to the top-level page \u2014 use this when the content you need is inside an embedded frame.", + "guidance": [ + "SEND null TO GET BACK TO THE TOP-LEVEL PAGE. Until you do, every find_element and the page source apply to the frame only, which is the usual reason an element that is plainly on screen cannot be found.", + "An index counts the frames in document order from 0, and an out-of-range one answers 404 \"no such frame\".", + "A frame can also be entered by passing an element id found for the iframe itself.", + "Element ids do not survive a frame switch.", + "Send the index as a number and null as a real null. A quoted \"0\" or \"null\" is refused with \"invalid argument\" rather than treated as a frame." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "id", + "type": "any", + "required": true, + "description": "The frame's index as a NUMBER, or an element reference for the iframe, or null to return to the top-level page. The type matters: sent as text, each of these is refused as an invalid argument.", + "example": 0 + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_cookies", + "method": "GET", + "path": "/wd/hub/session/{session_id}/cookie", + "mode": "read", + "entity": "browser_session", + "intent": "Read the cookies for the current page \u2014 use this to check a login or consent cookie was set.", + "guidance": [ + "Only cookies visible to the current page's domain; navigating elsewhere gives a different set.", + "An empty list means none for this domain, not an error.", + "A session that has not navigated anywhere yet has no cookies at all." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The cookies for this domain.", + "schema": { + "$schema": "CookieListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "delete_session", + "method": "DELETE", + "path": "/wd/hub/session/{session_id}", + "mode": "write", + "entity": "browser_session", + "intent": "End a browser session and release the browser \u2014 use this when the flow is finished, after marking it passed or failed.", + "guidance": [ + "Mark the outcome with set_session_status FIRST; an unmarked session reads as abandoned on the dashboard.", + "The session record, video and logs survive and stay readable with get_session.", + "A session already ended answers 404, which means it is gone rather than that something failed.", + "Every session consumes browser minutes until it ends, so end it even when the run failed." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "state", + "value" + ], + "responses": { + "200": { + "description": "state is \"success\" and value an empty string; the body is the hub's own, not the W3C null.", + "schema": { + "$schema": "SessionDeletedResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + } + ], + "schemas": { + "StringValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + }, + "NullableStringResponse": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Null when the attribute is absent." + } + } + }, + "BooleanValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "boolean" + } + } + }, + "NullValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "null" + } + } + }, + "AnyValueResponse": { + "type": "object", + "properties": { + "value": { + "description": "Whatever the call produced; the shape depends on what was asked for." + } + } + }, + "StringListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ElementResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string", + "description": "The element id to pass as element_id." + } + } + } + } + }, + "ElementListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string" + } + } + } + } + } + }, + "RectResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + } + } + }, + "NewWindowResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "handle": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + }, + "CookieListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "path": { + "type": "string" + }, + "secure": { + "type": "boolean" + }, + "httpOnly": { + "type": "boolean" + }, + "expiry": { + "type": "integer" + } + } + } + } + } + }, + "NewSessionResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "Carry this into every other browser_session capability." + }, + "capabilities": { + "type": "object", + "description": "What the hub negotiated, including the browser version actually given." + } + } + } + } + }, + "WebDriverErrorBody": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "The WebDriver error code, such as \"no such element\"." + }, + "message": { + "type": "string" + }, + "stacktrace": { + "type": "string" + } + } + } + } + }, + "BrowserList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "browser": { + "type": "string", + "description": "Lower case, as create_session takes it." + }, + "browser_version": { + "type": "string", + "description": "Null for a mobile browser, which ships with the OS." + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "device": { + "type": "string", + "description": "Null for a desktop combination." + }, + "real_mobile": { + "type": "boolean", + "description": "True for a real phone or tablet." + } + } + } + }, + "Plan": { + "type": "object", + "properties": { + "automate_plan": { + "type": "string" + }, + "parallel_sessions_running": { + "type": "integer", + "description": "Account-wide, including other people's sessions." + }, + "parallel_sessions_max_allowed": { + "type": "integer" + }, + "team_parallel_sessions_max_allowed": { + "type": "integer" + }, + "queued_sessions": { + "type": "integer" + }, + "queued_sessions_max_allowed": { + "type": "integer" + }, + "terminal_access": { + "type": "string" + } + } + }, + "SessionStatusUpdate": { + "type": "object", + "description": "The session record after the change.", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "browser_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "device": { + "type": "string", + "description": "Set for a mobile browser, null for a desktop one." + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + } + } + } + } + }, + "SessionDetails": { + "type": "object", + "description": "The session record, wrapped in automation_session.", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "browser": { + "type": "string" + }, + "browser_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "device": { + "type": "string", + "description": "Set for a mobile browser, null for a desktop one." + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + }, + "duration": { + "type": "integer", + "description": "Seconds; null while the session is still running." + }, + "created_at": { + "type": "string" + }, + "public_url": { + "type": "string", + "description": "Shareable link needing no login; carries an auth token, so treat it as a secret." + }, + "browser_url": { + "type": "string", + "description": "The session on the dashboard; needs a login." + }, + "video_url": { + "type": "string", + "description": "Complete only once the session has ended." + }, + "har_logs_url": { + "type": "string", + "description": "Present only when the session was started with network logs." + }, + "browser_console_logs_url": { + "type": "string", + "description": "Present only when console logs were asked for." + }, + "browser_profiling_url": { + "type": "string" + }, + "browserstack_status": { + "type": "string", + "description": "What BrowserStack observed, which can differ from the status that was set." + } + } + } + } + }, + "SessionDeletedResponse": { + "type": "object", + "description": "What the hub returns when a session is closed. Not the W3C null: state says whether it closed and value is an empty string.", + "properties": { + "state": { + "type": "string", + "description": "\"success\" when the session was closed." + }, + "status": { + "type": "integer", + "description": "The hub's own legacy status code; 0 on success." + }, + "value": { + "type": "string", + "description": "Always empty on success." + } + } + } + }, + "responses": { + "WebDriverError": { + "description": "A WebDriver failure, naming the error code and the browser's own message.", + "schema": { + "$schema": "WebDriverErrorBody" + } + }, + "Unauthorized": { + "description": "The credentials were rejected.", + "schema": { + "$schema": "WebDriverErrorBody" + } + } + }, + "paging": {}, + "entities": { + "browser_session": { + "title": "Browser Session", + "description": "A live browser on one operating system, driven page by page until it ends.", + "aliases": [ + "browser session", + "automate session", + "web session", + "selenium session", + "webdriver session", + "browser", + "session", + "test session" + ], + "id_convention": "session_id is a 40-character hex string returned by create_session at value.sessionId; it cannot be listed or guessed. element_id is an opaque string from find_element and is valid only until the page navigates or that part of the page re-renders.", + "key_facts": [ + "A session holds a browser and consumes browser minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", + "Element ids go stale the moment the page navigates, reloads or re-renders, and a stale one is refused rather than silently acting on the wrong thing.", + "Console and network logs must be asked for when the session starts; they cannot be turned on later.", + "Each session is recorded with video and its record outlives it, including the shareable link and the passed or failed status.", + "Everything inside an iframe is invisible until the session is switched into it, and invisible again until it is switched back out." + ], + "capabilities": [ + "get_session", + "set_session_status", + "create_session", + "navigate_to", + "get_current_url", + "get_title", + "go_back", + "go_forward", + "refresh_page", + "get_page_source", + "find_element", + "find_elements", + "click_element", + "send_keys", + "clear_element", + "get_element_text", + "get_element_property", + "get_element_attribute", + "is_element_displayed", + "execute_script", + "take_screenshot", + "get_window_rect", + "set_window_rect", + "maximize_window", + "list_window_handles", + "switch_to_window", + "open_new_window", + "switch_to_frame", + "get_cookies", + "delete_session", + "scroll_to_element", + "scroll_page" + ] + }, + "browser": { + "title": "Browser", + "description": "One operating system, browser and version combination a session can run on.", + "aliases": [ + "browser", + "browser version", + "os", + "platform", + "combination", + "browser list", + "capability", + "desktop browser", + "mobile browser" + ], + "id_convention": "A combination is addressed by its browser name with either os and os_version, or device and os_version for a mobile browser; there is no id, and the strings must match the list exactly.", + "key_facts": [ + "Several thousand combinations exist, so the list is large and has to be narrowed by the caller.", + "A mobile browser is named after its platform rather than its product, so the name that works on a phone is not the one a person would say.", + "A mobile browser is the one that ships with the device's OS, so it has no separate version to choose.", + "The set changes as browser versions are released and retired, so a combination held from an earlier run can stop being offered." + ], + "capabilities": [ + "list_browsers" + ] + }, + "plan": { + "title": "Plan", + "description": "The account's browser-session subscription and how much of its parallel-session allowance is in use.", + "aliases": [ + "plan", + "subscription", + "parallel sessions", + "parallels", + "concurrency", + "quota", + "capacity", + "limits", + "usage" + ], + "id_convention": "There is one plan per account; it is not addressed by an id.", + "key_facts": [ + "Parallel-session counts are account-wide and include sessions started by other people and by CI.", + "A team can have a lower ceiling than the account as a whole.", + "Starting past the limit queues the session rather than refusing it, which looks like a slow start.", + "Web and mobile-app sessions are counted against separate plans." + ], + "capabilities": [ + "get_plan" + ] + } + } + } +} diff --git a/capability/mobiledevicecontrol.capability-index.json b/capability/mobiledevicecontrol.capability-index.json new file mode 100644 index 00000000..2f0aecb5 --- /dev/null +++ b/capability/mobiledevicecontrol.capability-index.json @@ -0,0 +1,3782 @@ +{ + "schema_version": 1, + "version": "1.0", + "build_id": "6782f7e_2026-10-16T13:54:22Z", + "mobiledevicecontrol": { + "title": "Mobile Device Control", + "summary": "Mobile device control: run a real Android or iOS device and drive the app on it step by step. The account side lists the devices this account can use, including private ones, uploads and lists app builds, reports parallel-session capacity, and keeps each session's record \u2014 status, shareable link and video \u2014 after the device is released. The session side starts a device with an installed build and drives it: find elements and read their text, position and attributes, tap them, type, swipe, screenshot, press keys, rotate, switch between the native app and a web view, move between the native app and a web view, and launch or close apps. Apps are RUN here \u2014 unlike Test Management, which authors cases, and Test Reporting, which records results pushed in from a runner. Browsers are not here \u2014 those are device control, which drives a page rather than an installed app \u2014 and neither is App Live: it has no API an agent can drive, so agent sessions run here, on real devices, and consume the account's device minutes. A build must exist before a session starts; it is uploaded here from a public link, or from a file on the user's own machine when the server runs there. App Live keeps a separate store, so an app uploaded for manual App Live testing is not available to sessions here. Media for camera injection must come from elsewhere. The default Appium server is 1.x and rejects every `mobile:` command; ask for 2.19.0.", + "routing_terms": [ + "app automate", + "app build", + "app for automation", + "appium", + "appium session", + "close app", + "device control", + "device list", + "device property", + "drive a device", + "drive the app", + "foreground app", + "getprop", + "hardware button", + "home button", + "keyboard", + "keyboard open", + "launch app", + "local build", + "mobile app", + "mobile device control", + "os build", + "parallel sessions", + "plan", + "private device", + "public link", + "real device", + "restart app", + "screenshot", + "scroll", + "session video", + "swipe", + "tap", + "upload apk", + "upload app", + "upload app for device", + "upload ipa", + "volume", + "webdriver" + ], + "base_url": "https://hub-cloud.browserstack.com", + "auth": { + "type": "http", + "scheme": "basic" + }, + "capabilities": [ + { + "name": "create_session", + "method": "POST", + "path": "/wd/hub/session", + "mode": "write", + "timeout_seconds": 180, + "entity": "app_session", + "intent": "Start a live Appium session on a real Android or iOS device with an uploaded app \u2014 use this before reading the screen, tapping, typing or swiping in the app.", + "guidance": { + "shared": [ + "The app must already be uploaded: pass the bs:// URL from upload_app_build or list_app_builds, or the custom_id it was uploaded under, which always resolves to the newest build with that id.", + "Allocation and app install usually take 20\u201330 seconds; the response carries the new session id at value.sessionId \u2014 carry it into every other app_session call.", + "The session ends itself after idle_timeout seconds with no command (90 by default, 900 at most) and after 2 hours in total. Keep calling, or call get_window_rect as a cheap keep-alive, while deciding the next step.", + "Every session occupies a device and consumes device minutes until it ends: always finish with set_session_status and then delete_session.", + "To let a person watch the device live while the agent drives it, set interactive_debugging true and leave video on; they then open the running session on the session dashboard and start the interactive session there. That window is two-way: a person's taps and scrolls reach the same device, so ask them to watch only, or find the element you are about to act on again before every action.", + "device_name and platform_version must be a combination mobile device control offers; an unknown pair fails with a session-not-created error naming the capability.", + "Set appium_version to 2.19.0 for any session that will scroll with scroll_gesture, dismiss an iOS keyboard with dismiss_keyboard_key, or use any other mobile: capability; the default server is 1.x and answers those with a 404 listing the commands it does know.", + "enable_biometric, enable_camera_image_injection and upload_media only take effect when set here \u2014 none of them can be turned on once the session is running.", + "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.", + "device_name and platform_version come from list_devices; get_plan first if sessions are queueing rather than starting.", + "Treat a response without value.sessionId as a failed start even when the status is 200, and treat a failed call the same way: allocation can outlast the client's own timeout, so BrowserStack may have started a session the answer never named. One is then holding a device and consuming minutes until its idle timeout.", + "After a start that failed or returned no session id, look for a session left running on the account and end it rather than starting another.", + "The response carries no link of any kind \u2014 it is the session id and the negotiated capabilities only. Call get_session for the links: browser_url to watch the run on the dashboard, and public_url to share it with someone who has no BrowserStack account.", + "LANGUAGE AND LOCALE LAND IN DIFFERENT PLACES ON THE TWO PLATFORMS. On Android they change the device, and the app with it \u2014 measured with language de and locale DE: the system's own permission dialog came up in German and the app rendered German throughout. On iOS they leave the device alone \u2014 Settings and General stay in English \u2014 and apply to the app under test at launch. Either way nothing is visible unless the app actually ships that language, so test with an app that does and read the strings back with get_element_text rather than trusting the call's 200.", + "Both are accepted without complaint on both platforms, so a 200 says nothing about whether anything changed. There is also no way to confirm the device locale from inside the session: read_device_property refuses getprop persist.sys.locale and ro.product.locale as unsupported commands. Judge it from what is on screen.", + "Someone can only WATCH AND INTERACT if interactive_debugging was set true in this call \u2014 it cannot be turned on once the session is running, it is off by default on Android, and it needs video left on. Ask for it up front when a person is going to follow along, because the alternative is restarting the session." + ], + "local": [ + "ON THE USER'S OWN MACHINE YOU CAN OPEN THE LIVE VIEW FOR THEM. This call returns only value.sessionId; call get_session straight after it, then run the shell command open \"\" on macOS or xdg-open \"\" on Linux, and say you opened the session on the session dashboard \u2014 it needs a BrowserStack login in that browser. Set interactive_debugging true in this call when they should be able to tap as well as watch; without it the view is watch-only." + ], + "remote": [ + "THIS CALL RETURNS NO LINK \u2014 only value.sessionId. Nothing here can open a browser for the user either: this surface makes HTTP requests, and on a remote server there is no browser to open. So never say a live view was opened. If the user may want to watch, call get_session straight after this and give them browser_url in the same reply, which is the session on the dashboard and needs a BrowserStack login. public_url needs no login but carries an auth token, so offer it only when they ask to share outside the team." + ] + }, + "body": [ + { + "name": "platform_name", + "type": "string", + "required": true, + "values": [ + "android", + "ios" + ], + "json_path": "/capabilities/alwaysMatch/platformName", + "example": "android" + }, + { + "name": "device_name", + "type": "string", + "required": true, + "json_path": "/capabilities/alwaysMatch/appium:deviceName", + "description": "Device model exactly as list_devices gives it.", + "example": "Google Pixel 6" + }, + { + "name": "platform_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/appium:platformVersion", + "description": "OS version of that device, as list_devices gives it. Omit to let BrowserStack pick one for the device.", + "example": "12.0" + }, + { + "name": "app", + "type": "string", + "required": true, + "json_path": "/capabilities/alwaysMatch/appium:app", + "description": "The bs:// app URL returned by upload_app_build, or the custom_id given at upload time (the latest app with that id is used).", + "example": "bs://0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "auto_grant_permissions", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/appium:autoGrantPermissions", + "description": "Android only: grant every runtime permission at install so permission dialogs do not appear.", + "example": true + }, + { + "name": "idle_timeout", + "type": "integer", + "minimum": 0, + "maximum": 900, + "default": 90, + "json_path": "/capabilities/alwaysMatch/bstack:options/idleTimeout", + "description": "Seconds the session may sit without a command before BrowserStack ends it. Raise it when an agent drives the session step by step and may pause to think, or while a human watches; the device is held, and billed, for the whole idle period.", + "example": 300 + }, + { + "name": "appium_version", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/appiumVersion", + "description": "Appium server version to run. The default is 1.x, which has none of the `mobile:` commands execute_script relies on \u2014 they come back 404 with a list of what that server does support. Set 2.19.0 unless something needs the older server.", + "example": "2.19.0" + }, + { + "name": "project_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/projectName", + "description": "Groups sessions on the session dashboard.", + "example": "Mobile checkout" + }, + { + "name": "build_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/buildName", + "description": "Build the session is filed under on the dashboard.", + "example": "nightly" + }, + { + "name": "session_name", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/sessionName", + "description": "Name shown for this session on the dashboard.", + "example": "sign in and add to basket" + }, + { + "name": "interactive_debugging", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/interactiveDebugging", + "description": "Let a person watch and interact with the device from the dashboard while the session runs. Off by default on Android; needs video left on.", + "example": true + }, + { + "name": "video", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/video", + "description": "Record a video of the session, viewable on the dashboard afterwards. On by default; set false to skip recording when the screen shows sensitive data. interactive_debugging stops working if this is false.", + "default": true, + "example": false + }, + { + "name": "automation_name", + "type": "string", + "values": [ + "UiAutomator2", + "XCUITest" + ], + "json_path": "/capabilities/alwaysMatch/appium:automationName", + "description": "Driver to use. Omit to let the hub pick by platform; name it when a session must not fall back to another driver.", + "example": "UiAutomator2" + }, + { + "name": "auto_accept_alerts", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/appium:autoAcceptAlerts", + "description": "iOS only: dismiss system dialogs automatically. Leave false to see and answer permission prompts yourself.", + "example": false + }, + { + "name": "new_command_timeout", + "type": "integer", + "minimum": 0, + "json_path": "/capabilities/alwaysMatch/appium:newCommandTimeout", + "description": "Seconds the Appium server itself waits for the next command before ending the session. Distinct from idle_timeout, which is BrowserStack's own backstop; the shorter of the two wins.", + "example": 300 + }, + { + "name": "language", + "type": "string", + "json_path": "/capabilities/alwaysMatch/appium:language", + "description": "Language code applied at session start. On Android it sets the DEVICE language \u2014 the system's own dialogs render in it \u2014 and the app follows. On iOS it does NOT change the device: Settings stays in English, and the code is applied to the app under test at launch instead.", + "example": "de" + }, + { + "name": "locale", + "type": "string", + "json_path": "/capabilities/alwaysMatch/appium:locale", + "description": "Region applied with `language`, same split: the device on Android, the app under test on iOS. Android takes a bare region code such as DE.", + "example": "DE" + }, + { + "name": "timezone", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/timezone", + "description": "Device time zone, as a city name rather than an offset.", + "example": "Paris" + }, + { + "name": "gps_location", + "type": "string", + "pattern": "^-?\\d+(\\.\\d+)?,-?\\d+(\\.\\d+)?$", + "json_path": "/capabilities/alwaysMatch/bstack:options/gpsLocation", + "description": "Starting GPS position as 'latitude,longitude'. Use set_geo_location to move it later.", + "example": "48.8566,2.3522" + }, + { + "name": "network_profile", + "type": "string", + "json_path": "/capabilities/alwaysMatch/bstack:options/networkProfile", + "values": [ + "no-network", + "2g-gprs-good", + "2g-gprs-lossy", + "3g-umts-good", + "3g-umts-lossy", + "4g-lte-good", + "4g-lte-advanced-good", + "4g-lte-advanced-lossy" + ], + "description": "Start the session under a named network profile. set_session_network changes it later.", + "example": "3g-umts-good" + }, + { + "name": "network_logs", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/networkLogs", + "description": "Capture the app's network traffic, readable from the session afterwards.", + "example": true + }, + { + "name": "enable_biometric", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/enableBiometric", + "description": "Instrument the app so a fingerprint, Touch ID or Face ID prompt can be answered during the session. Must be set at start; it cannot be turned on later.", + "example": true + }, + { + "name": "enable_camera_image_injection", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/enableCameraImageInjection", + "description": "Instrument the app so an uploaded image can be fed to its camera. Must be set at start; it cannot be turned on later.", + "example": true + }, + { + "name": "upload_media", + "type": "array", + "json_path": "/capabilities/alwaysMatch/bstack:options/uploadMedia", + "maxItems": 10, + "description": "media:// URLs of files to preload into the device's gallery or storage before the app opens.", + "example": [ + "media://0123456789abcdef0123456789abcdef01234567" + ] + }, + { + "name": "device_tunnel", + "type": "boolean", + "json_path": "/capabilities/alwaysMatch/bstack:options/deviceTunnel", + "description": "Open a tunnel to the device. Private (dedicated) devices only; BrowserStack rejects it on shared ones.", + "example": true + } + ], + "returns": [ + "value.sessionId", + "value.capabilities" + ], + "responses": { + "200": { + "description": "Session created.", + "schema": { + "$schema": "NewSessionResponse" + } + }, + "401": { + "$response": "Unauthorized" + }, + "500": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_page_source", + "method": "GET", + "path": "/wd/hub/session/{session_id}/source", + "mode": "read", + "entity": "app_session", + "intent": "Read the full UI tree of the screen currently shown in the app \u2014 use this to see what is on screen and choose what to tap, type into or scroll before acting.", + "guidance": [ + "Not published: the whole tree runs to tens of kilobytes, more than a caller can read. Find the elements you need with find_element or find_elements and read them with get_element_text, get_element_rect and get_element_attribute." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "description": "value.sessionId from create_session.", + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The UI tree as an XML string.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "find_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element", + "mode": "read", + "entity": "app_session", + "intent": "Locate one element on the current screen by id, accessibility id, XPath or UiAutomator selector \u2014 use this to get the element id that click_element and send_keys act on.", + "guidance": [ + "The element id comes back under the W3C key value[\"element-6066-11e4-a52e-4f735466cecf\"]; pass that string as element_id.", + "Locators by platform: resource-id \u2192 \"id\", content-desc (Android) or name (iOS) \u2192 \"accessibility id\", visible text \u2192 xpath such as //*[@text='Submit'] on Android or -ios predicate string label == 'Submit' on iOS. Once you hold an element, get_element_attribute reads its ids and get_element_text what it says.", + "No match is a 404 with error \"no such element\" \u2014 the screen may have changed, or the element may be off-screen and need a scroll first; find it again after the scroll. \"No such element\" is also the cheapest way to confirm something is NOT on screen.", + "\"-android uiautomator\" accepts a UiSelector expression, which can scroll an element into view: new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text(\"Checkout\")).", + "find_elements answers the same query with a list and never a 404, which is how to ask whether something is present without treating absence as a failure." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "id", + "accessibility id", + "xpath", + "class name", + "-android uiautomator", + "-ios predicate string", + "-ios class chain" + ], + "example": "accessibility id" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": "cart-button" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The matching element reference.", + "schema": { + "$schema": "ElementResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "click_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/click", + "mode": "write", + "entity": "app_session", + "intent": "Tap an element in the app \u2014 use this to press a button, open a menu item, select a list row or focus a field found with find_element.", + "guidance": [ + "element_id must come from find_element on the current screen; after the screen changes it is stale and fails with \"stale element reference\".", + "To tap something that has no usable locator, take the centre of a nearby element's get_element_rect and tap the coordinates with perform_actions instead.", + "Confirm the tap had the intended effect \u2014 a 200 only means it was delivered: find an element the new screen should carry, or read a label with get_element_text before and after.", + "A keyboard covers the lower part of the screen and a tap aimed underneath it lands on the keyboard while still reporting success. When the target sits low, call is_keyboard_shown and then hide_keyboard before tapping." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "value[\"element-6066-11e4-a52e-4f735466cecf\"] from find_element.", + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Tap delivered; value is null.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "send_keys", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/value", + "mode": "write", + "entity": "app_session", + "intent": "Type text into an input field in the app \u2014 use this to fill a search box, login form or any editable field found with find_element.", + "guidance": [ + "WHETHER THIS REPLACES OR APPENDS DEPENDS ON THE PLATFORM: on Android it replaces whatever the field already holds, and on iOS it appends to it. Typing twice into one iOS field therefore leaves both values joined. Read the field back with get_element_text whenever the resulting value matters.", + "The element must be an editable field (EditText on Android, TextField on iOS); typing into anything else fails with \"invalid element state\".", + "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 check is_keyboard_shown, and compare get_element_rect of the next target with the screen's lower part, before the next tap.", + "clear_element empties a field, and on iOS \u2014 where this APPENDS \u2014 it is the only way to take back what is already there. It answers 200 whether or not the field changed, so read the field back with get_element_text rather than trusting the status.", + "The keyboard is usually left open afterwards. On Android and on an iPad, hide_keyboard closes it. On an iPhone it cannot be closed without submitting, so read hide_keyboard's guidance before planning a tap below the field.", + "Sending a newline closes the keyboard on iOS by pressing Return, which an app that commits on editing-ended will treat as a submit \u2014 the field resets and the value moves wherever the app sends it. The commit is the app's doing, not the newline's: dismissing the same field any other way can commit it too. See hide_keyboard." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "body": [ + { + "name": "text", + "type": "string", + "required": true, + "description": "The characters to type.", + "example": "hello" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Text typed; value is null.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "clear_element", + "method": "POST", + "path": "/wd/hub/session/{session_id}/element/{element_id}/clear", + "mode": "write", + "entity": "app_session", + "intent": "Empty an editable field in the app \u2014 use this before typing when the field already holds something and you need to replace rather than add to it.", + "guidance": [ + "Takes no parameters of its own.", + "CONFIRM IT ACTUALLY EMPTIED THE FIELD by reading it back with get_element_text: this endpoint can answer 200 without the field changing, and the status alone does not tell you which happened.", + "Needed far more on iOS than on Android: send_keys REPLACES the field's contents on Android, so clearing first is usually unnecessary there, while on iOS it APPENDS and this is the only way to take back what is already in the field.", + "The element must be an editable field \u2014 EditText on Android, TextField on iOS. Anything else answers \"invalid element state\".", + "The element_id must come from find_element on the current screen; after the screen changes it is stale and fails with \"stale element reference\"." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element.", + "example": "element-6066-11e4-a52e-4f735466cecf" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Field emptied; value is null. The null says the call was accepted, not that the field changed \u2014 read the field back to know that.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "send_empty_body": true + }, + { + "name": "perform_actions", + "method": "POST", + "path": "/wd/hub/session/{session_id}/actions", + "mode": "write", + "entity": "app_session", + "intent": "Perform touch gestures on the device screen by coordinates \u2014 tap a point, swipe or scroll a list, long-press, or drag \u2014 using W3C pointer actions.", + "guidance": [ + "Coordinates are device pixels on Android and points on iOS \u2014 the space get_element_rect and get_window_rect report in; take them from there rather than from a screenshot, whose height can include the system bars.", + "Tap: one touch pointer with pointerMove(duration 0, x, y), pointerDown(button 0), pause(100), pointerUp(button 0).", + "Swipe up to scroll content down: pointerMove to a low point, pointerDown, pointerMove(duration 600) to a higher point, pointerUp. Keep the start and end inside the scrollable element's get_element_rect. For a plain scroll or swipe, scroll_gesture (Android) and scroll_view (iOS) need no coordinates at all.", + "Long-press: replace the pause duration with 1000 or more.", + "For a scroll without computing coordinates, scroll_gesture is simpler on Android and reports whether more content remains; on iOS use scroll_view; compute a swipe here only when neither fits.", + "To go back on an iOS screen that ignores press_back, swipe in from the left edge: a pointer sequence starting at exactly x=0 and moving right in several short steps. A single long move does not register." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "actions", + "type": "array", + "required": true, + "description": "W3C input sources. For touch use one source of type \"pointer\" with parameters {\"pointerType\":\"touch\"} and an actions list of pointerMove / pointerDown / pause / pointerUp items.", + "fields": [ + { + "name": "type", + "type": "string", + "required": true, + "values": [ + "pointer", + "key", + "none" + ] + }, + { + "name": "id", + "type": "string", + "required": true + }, + { + "name": "parameters", + "type": "object" + }, + { + "name": "actions", + "type": "array", + "required": true + } + ], + "example": [ + { + "type": "pointer", + "id": "finger1", + "parameters": { + "pointerType": "touch" + }, + "actions": [ + { + "type": "pointerMove", + "duration": 0, + "x": 540, + "y": 1900 + }, + { + "type": "pointerDown", + "button": 0 + }, + { + "type": "pointerMove", + "duration": 600, + "x": 540, + "y": 700 + }, + { + "type": "pointerUp", + "button": 0 + } + ] + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Gesture performed; value is null.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "400": { + "$response": "WebDriverError" + } + } + }, + { + "name": "scroll_gesture", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Swipe or scroll a region of the screen in a direction \u2014 use this to move through a list or page, or to swipe left and right through a carousel of cards, without working out start and end coordinates yourself.", + "guidance": [ + "ANDROID ONLY. On iOS this is a 405 \"unknown method\"; scroll_view is the iOS counterpart.", + "The returned value is true when the view can still scroll further in that direction and false when it cannot \u2014 but the FIRST scroll of a freshly opened list can answer false while the screen moved and more content followed, so treat false as a hint and confirm by finding the element you expect before deciding you are at the end.", + "A HORIZONTAL SWIPE IS THE SAME CALL with direction left or right: a rectangle covering the carousel's band of the screen, taken from get_window_rect, is enough \u2014 no element bounds are needed, and perform_actions is the harder way to do the same thing. DIRECTION NAMES WHERE THE CONTENT COMES FROM, as with a list: down brings what is below into view (the finger moves up), so right brings the NEXT card and left the previous one.", + "The area is a rectangle in screen pixels \u2014 get_window_rect gives the screen size to base it on \u2014 and percent is how far to scroll within it, 0 to 1.", + "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "example": "mobile: scrollGesture", + "values": [ + "mobile: scrollGesture" + ] + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object: the rectangle to scroll inside, the direction (up, down, left or right), and how far.", + "example": [ + { + "left": 100, + "top": 500, + "width": 880, + "height": 1500, + "direction": "down", + "percent": 0.8 + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + } + }, + { + "name": "scroll_view", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Swipe or scroll a scrollable view on iOS in a direction, or until a named element is visible \u2014 use this to move through a list or a long screen, or to swipe left and right through a carousel.", + "guidance": [ + "iOS ONLY, and the counterpart of scroll_gesture. On Android the same command exists with a different shape and answers 400 \"invalid argument\" naming strategy and selector as missing; use scroll_gesture there.", + "Scroll by direction with {\"direction\":\"down\"}, or stop when something comes into view with {\"predicateString\":\"name == 'Submit'\", \"toVisible\": true} \u2014 the second is the reliable way to reach a known control without guessing how far to go.", + "A HORIZONTAL SWIPE IS THE SAME CALL with direction left or right, optionally with elementId naming the carousel; no coordinates are needed.", + "Pass elementId in args to scroll inside that view rather than the whole screen; without one it scrolls the first scrollable view it finds, which may not be the one you meant.", + "Needs Appium 2.x: set appium_version to 2.19.0 at session start, since the default 1.x server answers mobile: commands with a 404 listing what it does know.", + "Confirm the screen actually moved \u2014 find the element you were scrolling toward, or compare get_element_rect of one element before and after \u2014 since this answers 200 even on a screen with nothing scrollable: a 200 only says the gesture was delivered." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: scroll" + ], + "example": "mobile: scroll" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object: a direction, or a predicateString with toVisible, optionally with elementId naming the view to scroll inside.", + "example": [ + { + "direction": "down" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + } + }, + { + "name": "dismiss_keyboard_key", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Close the on-screen keyboard by pressing one of its own keys \u2014 use this on an iPhone, where the plain hide_keyboard is refused.", + "guidance": [ + "hide_keyboard already presses these keys on an iPhone when its plain call is refused \u2014 Done-type keys first, then Return \u2014 and reports each attempt. Call this directly to press exactly the key you choose: Return alone, or every key but Return when a submit must not happen.", + "The key must EXIST on that keyboard or this returns the same 400: list the keyboard's keys with find_elements using class name XCUIElementTypeKey and read each with get_element_text, then pick from the keys that are really there. An iPad carries \"Hide keyboard\"; an iPhone keyboard of this kind usually offers only Return.", + "Several candidates may be listed in one call and it costs nothing, but it still fails when the keyboard carries none of them \u2014 measured with [\"Done\",\"done\",\"Hide keyboard\",\"Dismiss\"].", + "PRESSING RETURN SUBMITS. An app that commits when the field stops being edited will commit however the keyboard is closed, so check the field afterwards rather than assuming only the keyboard moved.", + "Confirm with is_keyboard_shown; on Android and on an iPad hide_keyboard closes it on its own." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "example": "mobile: hideKeyboard", + "values": [ + "mobile: hideKeyboard" + ] + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object whose keys field lists the key names to try, in order.", + "example": [ + { + "keys": [ + "Done", + "done", + "Hide keyboard", + "Dismiss" + ] + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + } + }, + { + "name": "execute_script", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Run an Appium mobile command or a BrowserStack session command \u2014 scroll a list, press a hardware key, launch or close an app, or mark the session passed or failed.", + "guidance": [ + "set_session_status is the capability for marking a session passed or failed, and works after the session has ended too. Only reach for the browserstack_executor setSessionStatus action here when something has to happen from inside the running session.", + "Keep any executor reason to one plain line under 256 characters with no escape sequences such as newlines.", + "Android scroll: script \"mobile: scrollGesture\" with args [{\"left\":100,\"top\":500,\"width\":880,\"height\":1500,\"direction\":\"down\",\"percent\":0.8}] \u2014 value true means more content remains below.", + "Which mobile: commands exist depends on the Appium version the session runs. The default Android version is Appium 1.x, which supports scrollGesture, swipeGesture, flingGesture, dragGesture, longClickGesture, doubleClickGesture and pinch gestures, but NOT mobile: pressKey \u2014 that needs a session started with appium_version 2.x or later.", + "An unsupported command fails with 404 \"unknown command\", and the message lists every mobile: command the session does support \u2014 read it rather than retrying variants." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "Either \"mobile: \" or a string beginning \"browserstack_executor: \" followed by a JSON object with action and arguments.", + "example": "mobile: scrollGesture" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Arguments for the command \u2014 for mobile: commands a one-element array holding an options object; for browserstack_executor an empty array.", + "example": [ + { + "left": 100, + "top": 500, + "width": 880, + "height": 1500, + "direction": "down", + "percent": 0.8 + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Command result; its shape depends on the command.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "500": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "get_window_rect", + "method": "GET", + "path": "/wd/hub/session/{session_id}/window/rect", + "mode": "read", + "entity": "app_session", + "intent": "Get the device screen size in pixels \u2014 use this to plan gesture coordinates, or as a cheap keep-alive so an idle session is not ended while deciding the next step.", + "guidance": [ + "On Android the height is the WHOLE screen including the status bar \u2014 2340 on a Galaxy S24 whose usable area starts 91px down \u2014 so the top strip of that rectangle is not the app's. On iOS it is the app's window in points." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value.width", + "value.height", + "value.x", + "value.y" + ], + "responses": { + "200": { + "description": "Screen rectangle.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "delete_session", + "method": "DELETE", + "path": "/wd/hub/session/{session_id}", + "mode": "write", + "entity": "app_session", + "intent": "End a live device session and release the device \u2014 use this when the flow is finished, after marking the session passed or failed.", + "guidance": [ + "There is no way to resume an ended session; any further step needs create_session again, which reinstalls the app.", + "The session's video, logs and pass/fail status stay on the session dashboard after it ends.", + "Mark the result with set_session_status first \u2014 it also works after the session has ended, so this is an ordering preference rather than a last chance." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "state", + "value" + ], + "responses": { + "200": { + "description": "state is \"success\" and value an empty string; the body is the hub's own, not the W3C null.", + "schema": { + "$schema": "SessionDeletedResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "list_devices", + "method": "GET", + "path": "/app-automate/devices.json", + "mode": "read", + "entity": "device", + "intent": "List the real Android and iOS devices this account can run an app session on, with the exact device names and OS versions a session accepts.", + "guidance": [ + "The device and os_version strings must be passed to create_session exactly as they appear here; a near miss is rejected rather than substituted.", + "One model appears once per OS version it is offered on, so the same device name repeats across rows.", + "This is what the account is entitled to, not what is free right now \u2014 a listed device can still be busy when a session starts.", + "There is no filtering on the wire: the whole catalogue comes back and the caller narrows it.", + "realMobile is true for physical devices and absent or false for emulators." + ], + "returns": [ + "device", + "os", + "os_version", + "device_tier", + "device_limit", + "realMobile" + ], + "responses": { + "200": { + "description": "Every device the account can use.", + "schema": { + "$schema": "DeviceList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "list_dedicated_devices", + "method": "GET", + "path": "/app-automate/dedicated_devices.json", + "mode": "read", + "entity": "device", + "intent": "List the account's private (dedicated) devices \u2014 use this to find devices reserved for this team rather than shared with other customers.", + "guidance": [ + "An account without a private device cloud gets an empty array, which is an answer and not an error.", + "Rows come back as the product sends them and carry more fields than the public catalogue; read the keys rather than assuming the public shape.", + "Private devices are the only ones a device tunnel can be opened on." + ], + "returns": [ + "device", + "os", + "os_version" + ], + "responses": { + "200": { + "description": "The account's private devices, or an empty array.", + "schema": { + "$schema": "DedicatedDeviceList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_plan", + "method": "GET", + "path": "/app-automate/plan.json", + "mode": "read", + "entity": "plan", + "intent": "Show the device-session plan and how many parallel sessions are running, queued and allowed \u2014 check this before starting a session or a batch of them.", + "guidance": [ + "A session start blocks and then queues when parallel_sessions_running has reached parallel_sessions_max_allowed, so a start that seems slow is usually this.", + "team_parallel_sessions_max_allowed is the ceiling shared across everyone on the team and can be lower than the account's own maximum.", + "The counts are account-wide and include sessions other people and CI started, not only this caller's." + ], + "returns": [ + "automate_plan", + "parallel_sessions_running", + "parallel_sessions_max_allowed", + "team_parallel_sessions_max_allowed", + "queued_sessions", + "queued_sessions_max_allowed", + "terminal_access" + ], + "responses": { + "200": { + "description": "Plan name and parallel usage.", + "schema": { + "$schema": "Plan" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "upload_app_build", + "method": "POST", + "path": "/app-automate/upload", + "mode": "write", + "timeout_seconds": 600, + "entity": "app_build", + "intent": "Upload an app build to the device-session store \u2014 the one sessions here install from, separate from App Live's \u2014 from a public download link, and get back the bs:// URL a session installs. Use this to make a new .apk, .aab or .ipa available to the devices an agent drives.", + "guidance": [ + "APP LIVE KEEPS A SEPARATE STORE. An app uploaded for App Live (manual, exploratory testing in a person's browser) is not available here, and one uploaded here does not appear in App Live \u2014 so when the app is going to be driven by you, tapped, typed into or scrolled by capability calls, upload it HERE, whatever the user calls the testing. The bs:// this returns is the one create_session installs.", + "Only the download-link form can be used here. The same endpoint also accepts a multipart upload carrying the file itself, which this surface cannot send \u2014 so for a build that exists only on the caller's own machine, give them that multipart request to run themselves and take the bs:// url it returns. The upload answers with the bs:// url directly: there is no intermediate storage, no presigned url and no second call. There is no way to hand the bytes over from here.", + "The url must be https and reachable by BrowserStack without credentials; a link behind a login fails on their side, not the caller's.", + "Pass custom_id to give the build a stable name, then later sessions can use that name in place of the bs:// URL and always get the newest build under it.", + "Re-uploading under the same custom_id adds a new build rather than replacing the old one; both stay listed.", + "Builds expire after 30 days, so a bs:// URL held from an earlier run can stop working.", + "Uploading a large build can take minutes \u2014 the call does not return until BrowserStack has fetched the whole file.", + "upload_app_build_from_file takes a build that is on this machine instead, where the server runs beside the user. It is not offered on a remote server, where a path would name the operator's disk rather than theirs.", + "Before uploading at all, check list_app_builds \u2014 a build from an earlier run is still usable for 30 days, and on a remote server reusing one is the only route that needs nothing from the user." + ], + "body": [ + { + "name": "url", + "type": "string", + "required": true, + "format": "uri", + "description": "Public https link BrowserStack downloads the build from.", + "example": "https://example.com/builds/app-release.apk" + }, + { + "name": "custom_id", + "type": "string", + "maxLength": 100, + "description": "Stable name for the build, reusable in place of the bs:// URL.", + "example": "my-app-staging" + } + ], + "returns": [ + "app_url", + "custom_id", + "shareable_id" + ], + "responses": { + "200": { + "description": "The uploaded build.", + "schema": { + "$schema": "AppUpload" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "list_app_builds", + "method": "GET", + "path": "/app-automate/recent_apps", + "mode": "read", + "entity": "app_build", + "intent": "List the app builds this user has uploaded, newest first, with their bs:// URLs \u2014 use this to reuse an existing build instead of uploading again.", + "guidance": [ + "An account with no uploads answers with a message object or a 404 rather than an empty array; treat both as 'nothing uploaded yet'.", + "This is only the calling user's own uploads \u2014 use list_team_app_builds for everyone on the team.", + "app_url is what create_session takes; app_id is an internal identifier and is not accepted there.", + "On a remote server this is the first thing to try: uploading from a path is not offered there, so an existing build is the only app a session can install without either a public link or the user running an upload by hand." + ], + "query": [ + { + "name": "limit", + "type": "integer", + "minimum": 1, + "maximum": 100, + "example": 20, + "description": "How many builds to return." + } + ], + "returns": [ + "app_name", + "app_version", + "app_url", + "app_id", + "custom_id", + "uploaded_at", + "shareable_id" + ], + "responses": { + "200": { + "description": "Recently uploaded builds, newest first.", + "schema": { + "$schema": "AppBuildList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "list_team_app_builds", + "method": "GET", + "path": "/app-automate/recent_group_apps", + "mode": "read", + "entity": "app_build", + "intent": "List app builds uploaded by anyone on the team, newest first \u2014 use this to find a colleague's or CI's build rather than only your own.", + "guidance": [ + "Same rows as list_app_builds, widened to the whole team; a build you uploaded appears in both.", + "An empty team history answers with a message object or a 404 rather than an empty array." + ], + "query": [ + { + "name": "limit", + "type": "integer", + "minimum": 1, + "maximum": 100, + "example": 20, + "description": "How many builds to return." + } + ], + "returns": [ + "app_name", + "app_version", + "app_url", + "app_id", + "custom_id", + "uploaded_at", + "shareable_id" + ], + "responses": { + "200": { + "description": "The team's recently uploaded builds.", + "schema": { + "$schema": "AppBuildList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_app_builds_by_custom_id", + "method": "GET", + "path": "/app-automate/recent_apps/{custom_id}", + "mode": "read", + "entity": "app_build", + "intent": "List every build uploaded under one custom id, newest first \u2014 use this to resolve a stable build name to the bs:// URL of its latest version.", + "guidance": [ + "The newest build under the id is the first row; passing the custom id itself to create_session has the same effect and is usually simpler.", + "An unknown custom id answers with a message object or a 404, not an empty array." + ], + "path_params": [ + { + "name": "custom_id", + "type": "string", + "required": true, + "description": "The stable name a build was uploaded under.", + "example": "my-app-staging" + } + ], + "returns": [ + "app_name", + "app_version", + "app_url", + "app_id", + "custom_id", + "uploaded_at", + "shareable_id" + ], + "responses": { + "200": { + "description": "Builds under that custom id.", + "schema": { + "$schema": "AppBuildList" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "get_session", + "method": "GET", + "path": "/app-automate/sessions/{session_id}.json", + "mode": "read", + "entity": "app_session", + "intent": "Get a session's record: its status, device, duration, the shareable public link, the video, and which app build ran \u2014 use this for the links to share or to check how a finished session ended.", + "guidance": [ + "browser_url is the session on the dashboard and needs a BrowserStack login \u2014 this is the link to give someone on the team who wants to watch or review the run.", + "public_url opens without any login, so it is the one to send outside the team; it carries an auth token, so treat it as a secret and do not paste it anywhere public.", + "video_url is only complete once the session has ended; during a run it may be missing or still filling.", + "This works for any session on the account, including ones this caller did not start.", + "status is the status that was set on the session; browserstack_status is what BrowserStack itself observed, and the two can disagree when a run was marked by hand.", + "app_details carries the build that ran, including its custom id, which is how to tell which version a past session used." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "status", + "reason", + "device", + "os", + "os_version", + "duration", + "browser_url", + "public_url", + "video_url", + "build_name", + "project_name", + "created_at", + "app_details", + "browserstack_status", + "hashed_id", + "name" + ], + "responses": { + "200": { + "description": "The session record.", + "schema": { + "$schema": "SessionDetails" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "set_session_status", + "method": "PUT", + "path": "/app-automate/sessions/{session_id}.json", + "mode": "write", + "entity": "app_session", + "intent": "Mark a session passed or failed with a reason, so the dashboard shows the outcome \u2014 use this to record the verdict of what was driven on the device.", + "guidance": [ + "Works after the session has ended as well as during it, so the verdict can be set once the result is known.", + "Only 'passed' and 'failed' are accepted; any other value is rejected with a 422.", + "reason is truncated at 255 characters.", + "An unset session shows as neither passed nor failed on the dashboard, which reads as an abandoned run." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "status", + "type": "string", + "required": true, + "values": [ + "passed", + "failed" + ], + "example": "passed" + }, + { + "name": "reason", + "type": "string", + "maxLength": 255, + "description": "Short explanation shown beside the status.", + "example": "The app behaved as expected" + } + ], + "returns": [ + "status", + "reason", + "hashed_id", + "name", + "device", + "os", + "os_version", + "build_name", + "project_name" + ], + "responses": { + "200": { + "description": "The updated session record.", + "schema": { + "$schema": "SessionStatusUpdate" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api" + }, + { + "name": "set_session_network", + "method": "PUT", + "path": "/app-automate/sessions/{session_id}/update_network.json", + "mode": "write", + "entity": "app_session", + "intent": "Change the network conditions of a running session to a named profile \u2014 use this to see how the app behaves on a slow or absent connection.", + "guidance": [ + "Mid-session changes take a named profile only; custom bandwidth or latency values are rejected with a 422.", + "'reset' restores the device's normal connection.", + "The session must still be running; this cannot be applied to a finished one.", + "To start a session already under a profile, pass network_profile to create_session instead." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "networkProfile", + "type": "string", + "required": true, + "values": [ + "no-network", + "2g-gprs-good", + "2g-gprs-lossy", + "3g-umts-good", + "3g-umts-lossy", + "4g-lte-good", + "4g-lte-advanced-good", + "4g-lte-advanced-lossy", + "reset" + ], + "example": "3g-umts-good" + } + ], + "returns": [ + "message" + ], + "responses": { + "200": { + "description": "Acknowledgement that the profile was applied.", + "schema": { + "$schema": "MessageResponse" + } + }, + "401": { + "$response": "Unauthorized" + } + }, + "host": "api", + "disabled": true + }, + { + "name": "take_screenshot", + "method": "GET", + "path": "/wd/hub/session/{session_id}/screenshot", + "mode": "read", + "entity": "app_session", + "intent": "Take a screenshot of what is on the device right now, as a base64 PNG \u2014 use this to see the screen as a user would, including anything the UI tree does not describe.", + "guidance": [ + "The image is returned inline as base64 in value and a phone screen runs to hundreds of kilobytes, so fetch it only when the picture is needed and prefer find_element with get_element_text for deciding what to act on.", + "Screenshot pixels are the device's full resolution and can be taller than the rectangle get_window_rect reports, because that excludes the system bars; scale before reusing screenshot coordinates for gestures.", + "A screen the app marks secure comes back black, which is the app's doing and not a failure." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Base64-encoded PNG of the screen.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "press_back", + "method": "POST", + "path": "/wd/hub/session/{session_id}/back", + "mode": "write", + "entity": "app_session", + "intent": "Go back one screen \u2014 use this to leave a detail page, close a dialog, or undo a navigation step.", + "guidance": [ + "On Android this is the system back button and is the reliable way back.", + "On iOS this works on a screen that uses a standard navigation bar, where it acts on the bar's own back control. A screen that draws its own header instead is left where it was, and the call still answers 200 \u2014 so confirm by finding an element of the screen you expect rather than from the status.", + "Pressing back on the first screen of an app can send the app to the background and show the home screen; find one of the app's own elements afterwards to confirm where you are \u2014 \"no such element\" means it went to the background.", + "The response is null on success and says nothing about whether the screen actually changed.", + "When an iOS screen ignores it, swipe in from the left edge with perform_actions: start at exactly x=0 and move in several short steps. One long move, or a start at x=2, is ignored." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_orientation", + "method": "GET", + "path": "/wd/hub/session/{session_id}/orientation", + "mode": "read", + "entity": "app_session", + "intent": "Read whether the device is currently portrait or landscape.", + "guidance": [ + "The value is upper case, PORTRAIT or LANDSCAPE." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The current orientation.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_orientation", + "method": "POST", + "path": "/wd/hub/session/{session_id}/orientation", + "mode": "write", + "entity": "app_session", + "intent": "Rotate the device to portrait or landscape \u2014 use this to test a layout in the other orientation.", + "guidance": [ + "An app that locks its orientation refuses the rotation with a 400 'invalid element state' saying the screen could not be changed; that is the app's choice, not a broken call.", + "The value must be upper case, PORTRAIT or LANDSCAPE.", + "Element positions and the window rectangle change after a rotation, so re-read get_element_rect and get_window_rect before acting on coordinates.", + "The response carries the new orientation on Android and null on iOS, so treat a 200 rather than the value as the sign it worked, and read get_orientation if the value matters." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "orientation", + "type": "string", + "required": true, + "values": [ + "PORTRAIT", + "LANDSCAPE" + ], + "example": "LANDSCAPE" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The orientation now in effect.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "list_contexts", + "method": "GET", + "path": "/wd/hub/session/{session_id}/contexts", + "mode": "read", + "entity": "app_session", + "intent": "List the contexts available on screen \u2014 the native app and any web views \u2014 so a hybrid app's web content can be driven.", + "guidance": [ + "NATIVE_APP is always present; a web view appears as a WEBVIEW_ prefixed name only while one is actually on screen.", + "A screen with no web view returns NATIVE_APP alone, which means the content is native and not that something failed." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The available context names.", + "schema": { + "$schema": "StringListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_context", + "method": "POST", + "path": "/wd/hub/session/{session_id}/context", + "mode": "write", + "entity": "app_session", + "intent": "Switch between the native app and a web view \u2014 use this when the part of the screen you need to drive is web content inside the app.", + "guidance": [ + "Use a name exactly as list_contexts returned it; an invented WEBVIEW name is rejected.", + "In a web view, selectors change: find_element then takes css selector and get_element_text reads the page's text rather than a native label.", + "Switch back to NATIVE_APP before driving native controls again.", + "A web view's name carries the process id of the app that owns it, so it differs between sessions and after the app restarts. Read it from list_contexts each time rather than reusing one seen earlier." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "Context name from list_contexts.", + "example": "NATIVE_APP" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "hide_keyboard", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/hide_keyboard", + "mode": "write", + "entity": "app_session", + "intent": "Close the on-screen keyboard \u2014 use this when it covers a button or field you need to tap after typing.", + "guidance": [ + "On a plain success the response is true when a keyboard was dismissed and false when none was showing; false is not an error. A success reached through the chain below carries the key press's own null value instead.", + "A tap aimed at a control hidden behind the keyboard lands on the keyboard instead and reports success, so close it first rather than afterwards.", + "ON AN iPHONE THE PLAIN CALL IS REFUSED AND A CHAIN RUNS INSTEAD. That keyboard has no dismiss key, so the product answers 400 \"Did not know how to dismiss the keyboard\"; this then presses a key the keyboard actually carries \u2014 Done, Hide keyboard or Dismiss where one exists, and failing that Return. On an iPad the plain call closes it, because that keyboard carries \"Hide keyboard\"; on Android it closes it; neither runs the chain.", + "THE RESPONSE LISTS EVERY ATTEMPT WITH ITS STATUS under fallback.attempts, so read which one closed it. When the last attempt's keys include Return, Return is what was pressed \u2014 and in many apps that is a submit: a search runs, a form goes. Read the field and the screen back before assuming only the keyboard moved.", + "When no attempt answers 200 the keyboard stays up and the last 400 is the answer: the app offers no way to close it from outside, which a person on the phone would hit too. dismiss_keyboard_key is the same key press on its own, for trying exactly the key you choose \u2014 Return alone, or everything but Return.", + "GESTURES DO NOT DISMISS IT. Tapping an empty area above the keyboard returns 200 and changes nothing, and so does a drag downward over it \u2014 including one that starts well above the keyboard's top and moves through it in short steps, which is the form that works on a screen whose scroll view dismisses on drag. This app's does not, and neither gesture moved it.", + "WHETHER THE FIELD SURVIVES IS THE APP'S BUSINESS, NOT THE KEYBOARD'S. An app that commits on editing-ended will commit however the keyboard is dismissed \u2014 closing an iPad keyboard this way still moved the text out of the field. So do not expect any dismissal to be side-effect free; check the app's own behaviour. On Android it closes the keyboard, answers true, and leaves the field as it was.", + "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "fallbacks": [ + { + "when": { + "status": 400 + }, + "capability": "dismiss_keyboard_key", + "body": { + "script": "mobile: hideKeyboard", + "args": [ + { + "keys": [ + "Done", + "done", + "Hide keyboard", + "Dismiss" + ] + } + ] + } + }, + { + "when": { + "status": 400 + }, + "capability": "dismiss_keyboard_key", + "body": { + "script": "mobile: hideKeyboard", + "args": [ + { + "keys": [ + "Return", + "return", + "Go", + "go", + "Next", + "next", + "Search", + "search" + ] + } + ] + } + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when a keyboard was dismissed.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "set_geo_location", + "method": "POST", + "path": "/wd/hub/session/{session_id}/location", + "mode": "write", + "entity": "app_session", + "intent": "Move the device's simulated GPS position \u2014 use this to test location-dependent behaviour without starting a new session.", + "guidance": [ + "The response is a position reading that does not reliably reflect the value just sent: on Android it has come back as the device's previous location. Confirm it afterwards \u2014 with get_geo_location on Android, or get_simulated_location on iOS.", + "latitude, longitude and altitude must be nested under a location object.", + "altitude is required by the wire format even when it does not matter; send 0.", + "The app may cache its last fix, so a change can need the app to request location again before it is visible.", + "Latitude and longitude are applied as sent; altitude is not preserved and reads back as 0, so do not rely on it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "location", + "type": "object", + "required": true, + "description": "The position to move to.", + "fields": [ + { + "name": "latitude", + "type": "number", + "required": true, + "minimum": -90, + "maximum": 90, + "example": 48.8566 + }, + { + "name": "longitude", + "type": "number", + "required": true, + "minimum": -180, + "maximum": 180, + "example": 2.3522 + }, + { + "name": "altitude", + "type": "number", + "required": true, + "example": 0 + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "A position reading, which may be the location before the change rather than after it.", + "schema": { + "$schema": "LocationResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "get_geo_location", + "method": "GET", + "path": "/wd/hub/session/{session_id}/location", + "mode": "read", + "entity": "app_session", + "intent": "Read the device's current simulated GPS position \u2014 use this to confirm a location change took effect.", + "guidance": [ + "Android only \u2014 iOS answers 500, saying the command is not supported. Read the position on iOS with get_simulated_location instead.", + "On Android this is the authoritative reading, and worth making when set_geo_location's own response is in doubt." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The current position.", + "schema": { + "$schema": "LocationResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "push_file", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/push_file", + "mode": "write", + "entity": "app_session", + "intent": "Write a file onto the device from base64 content \u2014 use this to put test data, a fixture or a document where the app will read it.", + "guidance": [ + "data must be base64; to push text, encode it first. A file on the caller's own disk cannot be sent as a path, only as its encoded content.", + "Android takes an absolute path such as /sdcard/Download/data.json.", + "iOS takes an app-container path in the form @bundle.id:documents/data.json and can only write inside apps that allow it.", + "The file lasts only as long as the session; the device is wiped afterwards.", + "The response is null on success and does not confirm the file is readable by the app." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "path", + "type": "string", + "required": true, + "description": "Destination path on the device.", + "example": "/sdcard/Download/data.json" + }, + { + "name": "data", + "type": "string", + "required": true, + "description": "Base64-encoded file content.", + "example": "aGVsbG8=" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "pull_file", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/pull_file", + "mode": "read", + "entity": "app_session", + "intent": "Read a file off the device as base64 \u2014 use this to check what the app wrote, such as a log, an export or a saved document.", + "guidance": [ + "The content comes back base64-encoded in value and must be decoded; a large file is returned whole, so pull only what is needed.", + "Paths are the same as for push_file: absolute on Android, @bundle.id:documents/... on iOS.", + "A missing path is a 404 rather than empty content.", + "This is POST because the path travels in the body; it changes nothing on the device." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "path", + "type": "string", + "required": true, + "description": "Path to read on the device.", + "example": "/sdcard/Download/data.json" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Base64-encoded file content.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "press_keycode", + "method": "POST", + "path": "/wd/hub/session/{session_id}/appium/device/press_keycode", + "mode": "write", + "entity": "app_session", + "intent": "Press a hardware or system key on an Android device by its key code \u2014 use this for home, enter, delete, volume or any key with no on-screen control.", + "guidance": [ + "Android only. iOS has no equivalent: use press_back to go back, and press_button for home and the volume keys.", + "Common codes: 3 home, 4 back, 66 enter, 67 delete, 61 tab, 82 menu, 84 search, 187 app switch, 24 volume up, 25 volume down, 26 power.", + "press_back is the clearer way to send 4.", + "The response is null on success and says nothing about what the key did.", + "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use press_back or press_button there." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "keycode", + "type": "integer", + "required": true, + "minimum": 0, + "maximum": 400, + "description": "Android KeyEvent code.", + "example": 66 + }, + { + "name": "metastate", + "type": "integer", + "minimum": 0, + "description": "Modifier bitmask, e.g. 1 for shift.", + "example": 1 + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "405": { + "description": "The command is not implemented on this platform, as \"unknown method\".", + "$response": "WebDriverError" + } + } + }, + { + "name": "get_current_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Find out which app is in the foreground right now \u2014 use this to confirm the app under test is showing and not the home screen or another app.", + "guidance": [ + "Returns the Android package name; it answers with the launcher's package when the app has been sent to the background.", + "This is the quickest way to learn the app under test's own package id, which launch_app and terminate_app both need.", + "Android only; get_active_app_info is the iOS equivalent and returns an object rather than a string.", + "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use get_active_app_info there." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: getCurrentPackage" + ], + "example": "mobile: getCurrentPackage" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The foreground app's package name.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + }, + "405": { + "description": "The command is not implemented on this platform, as \"unknown method\".", + "$response": "WebDriverError" + } + } + }, + { + "name": "launch_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Bring an app to the foreground, starting it if it is not running \u2014 use this to open the app under test, return to it, or switch to another installed app.", + "guidance": [ + "The app is named INSIDE `args`, which is the one object there: args [{\"appId\": \"com.example.app\"}] on Android, args [{\"bundleId\": \"com.example.app\"}] on iOS. Sending appId or bundleId as a top-level body field is refused here before anything is sent \u2014 \"unknown body: bundleId. accepted: args, script\" \u2014 and sending the wrong one for the platform is refused by the product with a 400 \"invalid argument\" naming the parameter it wanted, with no action taken.", + "Launching an app that is already in the foreground does nothing and still reports success.", + "To restart an app from a clean state, call terminate_app first and then this.", + "Succeeding means the app was activated, not that it finished drawing, so give it a moment and confirm before acting. An app that cannot be brought forward at all answers 400 rather than reporting success, and get_app_state reads 4 once it really is in front \u2014 sampled too early it can still report the home screen mid-switch.", + "The app must be installed \u2014 a package that is not there fails rather than installing it." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: activateApp" + ], + "example": "mobile: activateApp" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "terminate_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Close an app and stop it running \u2014 use this to end a session inside the app, or as the first half of a restart.", + "guidance": [ + "The app is named INSIDE `args`, which is the one object there: args [{\"appId\": \"com.example.app\"}] on Android, args [{\"bundleId\": \"com.example.app\"}] on iOS. Sending appId or bundleId as a top-level body field is refused here before anything is sent \u2014 \"unknown body: bundleId. accepted: args, script\" \u2014 and sending the wrong one for the platform is refused by the product with a 400 \"invalid argument\" naming the parameter it wanted, with no action taken.", + "Returns true when the app was running and false when it was not; false is not an error.", + "The app stays installed and keeps its data; only the running process is stopped.", + "This does not clear state between runs \u2014 relaunching shows the app where it was unless the app itself resets." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: terminateApp" + ], + "example": "mobile: terminateApp" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when a running app was stopped.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_app_state", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Check whether an app is installed, running or in the foreground \u2014 use this to confirm a launch or a close actually took effect.", + "guidance": [ + "The app is named INSIDE `args`, which is the one object there: args [{\"appId\": \"com.example.app\"}] on Android, args [{\"bundleId\": \"com.example.app\"}] on iOS. Sending appId or bundleId as a top-level body field is refused here before anything is sent \u2014 \"unknown body: bundleId. accepted: args, script\" \u2014 and sending the wrong one for the platform is refused by the product with a 400 \"invalid argument\" naming the parameter it wanted, with no action taken.", + "The value is a number: 0 not installed, 1 installed but not running, 2 running in the background and suspended, 3 running in the background, 4 running in the foreground.", + "4 is the only state in which the app is on screen and can be driven.", + "The same scale applies on both platforms. Sending an app to the background leaves it at 3 or 2 \u2014 the system suspends it a moment after it stops being visible \u2014 so treat anything below 4 as not on screen rather than waiting for one number. Launching it again returns it to 4." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: queryAppState" + ], + "example": "mobile: queryAppState" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The app's state as a number from 0 to 4.", + "schema": { + "$schema": "IntegerValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "is_app_installed", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Check whether an app is installed on the device \u2014 use this before launching one, or to confirm an uninstall.", + "guidance": [ + "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "get_app_state answers this and more in one call; prefer it when the running state also matters." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: isAppInstalled" + ], + "example": "mobile: isAppInstalled" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when the app is installed.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "uninstall_app", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Remove an app and its data from the device for the rest of the session \u2014 use this to test a first-run or upgrade path from a clean state.", + "guidance": [ + "Android takes appId and iOS takes bundleId \u2014 send exactly the one for the platform. The wrong name is refused with a 400 \"invalid argument\" that names the parameter it wanted, and no action is taken.", + "The app and everything it stored are gone for the rest of the session and nothing here puts them back; confirm with the user before removing the app under test.", + "It is recoverable only in the sense that the device is wiped when the session ends and the next session installs the app fresh.", + "Returns true when an app was removed and false when it was not installed.", + "Removing the app under test leaves the session on the home screen with nothing to drive." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: removeApp" + ], + "example": "mobile: removeApp" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the app. The key differs by platform.", + "fields": [ + { + "name": "appId", + "type": "string", + "description": "ANDROID ONLY: the package name. iOS rejects this name.", + "example": "com.example.app" + }, + { + "name": "bundleId", + "type": "string", + "description": "iOS ONLY: the bundle id. Android rejects this name.", + "example": "com.example.App" + } + ], + "example": [ + { + "appId": "com.example.app" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when an app was removed.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "simulate_biometric", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Answer a fingerprint, Touch ID or Face ID prompt with a success or a failure \u2014 use this to get past biometric authentication in the app.", + "guidance": [ + "The session must have been started with enable_biometric true; it cannot be turned on once the session is running.", + "Call it only while the prompt is actually on screen \u2014 with no prompt showing it fails with BROWSERSTACK_COMMAND_EXECUTION_FAILED.", + "biometricMatch takes \"pass\" or \"fail\"; \"fail\" is how to exercise the app's rejection path.", + "Afterwards, find the element the app shows on success or on failure to see what it did with the answer.", + "Every failure seen so far reports the same BROWSERSTACK_COMMAND_EXECUTION_FAILED regardless of cause, so it does not distinguish 'no prompt on screen' from 'the session was not started with enable_biometric'. Check both." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The browserstack_executor line for the biometric action, built as: browserstack_executor: {\"action\":\"biometric\",\"arguments\":{\"biometricMatch\":\"\"}}", + "example": "browserstack_executor: {\"action\":\"biometric\",\"arguments\":{\"biometricMatch\":\"pass\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always an empty array for an executor command.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Acknowledgement that the prompt was answered.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "inject_camera_image", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Make the app's camera see a chosen image instead of the device camera \u2014 use this to feed a QR code, barcode or document to a scanning flow.", + "guidance": [ + "The session must have been started with enable_camera_image_injection true; it cannot be turned on later.", + "imageUrl must be a media:// URL of a file already uploaded to BrowserStack. This surface cannot upload media, because that endpoint takes a file from disk rather than a link \u2014 the URL has to come from elsewhere.", + "An unusable URL fails with BROWSERSTACK_INVALID_IMAGE_URL_PASSED rather than silently showing nothing.", + "Inject before the app opens its camera; a camera already running keeps the previous source." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The browserstack_executor line for the cameraImageInjection action, built as: browserstack_executor: {\"action\":\"cameraImageInjection\",\"arguments\":{\"imageUrl\":\"media://\"}}", + "example": "browserstack_executor: {\"action\":\"cameraImageInjection\",\"arguments\":{\"imageUrl\":\"media://0123456789abcdef0123456789abcdef01234567\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always an empty array for an executor command.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Acknowledgement that the image was accepted.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "annotate_session", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Write a note into the session's timeline so it appears beside the video on the dashboard \u2014 use this to label what each step was doing for whoever reviews the run.", + "guidance": [ + "The note is attached at the moment of the call, so it lines up with that point in the video.", + "level takes info, debug, warn or error and changes how the note is shown.", + "This records commentary only; it has no effect on the device or on the session's pass/fail status." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "description": "The browserstack_executor line for the annotate action, built as: browserstack_executor: {\"action\":\"annotate\",\"arguments\":{\"data\":\"\",\"level\":\"\"}}", + "example": "browserstack_executor: {\"action\":\"annotate\",\"arguments\":{\"data\":\"Tapped Add to cart\",\"level\":\"info\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always an empty array for an executor command.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The note that was recorded.", + "schema": { + "$schema": "AnyValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "is_keyboard_shown", + "method": "GET", + "path": "/wd/hub/session/{session_id}/appium/device/is_keyboard_shown", + "mode": "read", + "entity": "app_session", + "intent": "Check whether the on-screen keyboard is up \u2014 use this before tapping something low on the screen, or to confirm a field really took focus.", + "guidance": [ + "A keyboard covers the lower part of the screen, so a tap aimed at a control under it lands on the keyboard and still reports success. Check here first when the target sits low.", + "True right after typing means the field took focus; false means the tap that was meant to focus it did not land.", + "Use hide_keyboard to close it, then check again rather than assuming it closed." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "True when the keyboard is on screen.", + "schema": { + "$schema": "BooleanValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "press_button", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "write", + "entity": "app_session", + "intent": "Press a hardware button on an iPhone or iPad \u2014 use this for home and the volume keys, which have no on-screen control.", + "guidance": [ + "iOS only; press_keycode is the Android equivalent and the two are not interchangeable.", + "The button names carry no separator: home, volumeup, volumedown.", + "Pressing home sends the app to the background \u2014 bring it back with launch_app, whose args object takes bundleId rather than appId on iOS, and confirm the app is in front again by finding one of its elements.", + "The response is null on success and says nothing about what the button did." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: pressButton" + ], + "example": "mobile: pressButton" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Exactly one object naming the button.", + "fields": [ + { + "name": "name", + "type": "string", + "required": true, + "values": [ + "home", + "volumeup", + "volumedown" + ], + "example": "home" + } + ], + "example": [ + { + "name": "home" + } + ] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Null on success.", + "schema": { + "$schema": "NullValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_simulated_location", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Read the simulated GPS position on an iPhone or iPad \u2014 use this to confirm a location change took effect.", + "guidance": [ + "iOS only; get_geo_location is the Android equivalent and is refused here.", + "Every field comes back null until a position has been set in this session, which means nothing has been simulated rather than that the device is at zero.", + "After set_geo_location this returns exactly the latitude, longitude and altitude that were sent, so it is the way to confirm what that call's own response does not.", + "altitude reads back as 0 whatever was sent; only latitude and longitude are meaningful." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: getSimulatedLocation" + ], + "example": "mobile: getSimulatedLocation" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The simulated position, with null fields when none has been set.", + "schema": { + "$schema": "LocationResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + }, + "disabled": true + }, + { + "name": "get_active_app_info", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Find out which app is in the foreground on an iPhone or iPad, with its bundle id \u2014 use this to confirm the app under test is showing rather than the home screen.", + "guidance": [ + "iOS only; get_current_app is the Android equivalent and is refused here.", + "bundleId is the value the app lifecycle capabilities need on iOS, so this is how to learn it mid-session.", + "name is often empty; read bundleId rather than relying on it.", + "The answer is an object, where the Android equivalent returns a bare package-name string." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "values": [ + "mobile: activeAppInfo" + ], + "example": "mobile: activeAppInfo" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The foreground app.", + "schema": { + "$schema": "ActiveAppInfo" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "read_device_property", + "method": "POST", + "path": "/wd/hub/session/{session_id}/execute/sync", + "mode": "read", + "entity": "app_session", + "intent": "Read one Android system property from the device, such as its OS build or manufacturer \u2014 use this for details the UI does not show.", + "guidance": [ + "Android only. iOS has no adb and the call is rejected there.", + "Only `getprop ` can be expressed: the accepted form is fixed, and a command that lists apps or system state cannot be sent through this capability. A public phone is shared between customers, and commands such as `pm list packages` or `dumpsys` report what other people's sessions left behind.", + "BrowserStack applies its own allow-list on top of this one and accepts only some properties on a shared phone; ro.build.version.release and ro.product.manufacturer are accepted, while a bare getprop is not. A property it refuses comes back as an execution failure rather than an empty value.", + "The device name and OS version are already in the session record, so get_session answers those without this.", + "The whole line must be sent exactly as shown, with no extra spaces: it is matched against a fixed form.", + "The allowed property list is narrow and locale is not on it: getprop persist.sys.locale and getprop ro.product.locale both come back HTTP 500, \"BROWSERSTACK_UNSUPPORTED_ADB_SHELL_COMMAND ... is not supported on BrowserStack currently\". ro.build.version.release and ro.product.manufacturer do work, so treat the refusal as a per-property allow-list rather than a broken capability." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "script", + "type": "string", + "required": true, + "pattern": "^browserstack_executor: \\{\"action\":\"adbShell\",\"arguments\":\\{\"command\":\"getprop [A-Za-z0-9_.\\-]+\"\\}\\}$", + "description": "The executor line carrying a single `getprop ` command. Any other command, or any change to the surrounding form, is refused before the request is sent.", + "example": "browserstack_executor: {\"action\":\"adbShell\",\"arguments\":{\"command\":\"getprop ro.build.version.release\"}}" + }, + { + "name": "args", + "type": "array", + "required": true, + "description": "Always empty.", + "example": [] + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The property's value as a string.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "upload_app_build_from_file", + "method": "POST", + "path": "/app-automate/upload", + "host": "api", + "mode": "write", + "timeout_seconds": 600, + "entity": "app_build", + "encoding": "multipart", + "local_only": true, + "intent": "Upload an app build that is on this machine to the device-session store and get back the bs:// URL a session installs \u2014 use this when the .apk, .aab or .ipa is a local file rather than a download link.", + "guidance": { + "shared": [ + "APP LIVE KEEPS A SEPARATE STORE. An app uploaded for App Live (manual, exploratory testing in a person's browser) is not available here, and one uploaded here does not appear in App Live \u2014 so when the app is going to be driven by you, tapped, typed into or scrolled by capability calls, upload it HERE, whatever the user calls the testing. The bs:// this returns is the one create_session installs.", + "Only .apk, .aab and .ipa are accepted.", + "A large build takes minutes, because the upload does not return until the whole file has been sent.", + "upload_app_build takes a public https link instead, and list_app_builds reuses a build already on the account \u2014 builds last 30 days." + ], + "local": [ + "The file must sit inside the directory MCP_UPLOAD_BASE_DIR names. When that is not set the upload is refused and nothing is sent: tell the user to set it to the folder holding their builds and restart the server.", + "When it is set but the build is somewhere else, the refusal names the directory the file has to be inside. Tell the user to move it there rather than trying another path.", + "No part of the path may be a hidden folder, so a build under a dot-directory has to be moved out of it.", + "The path is checked before anything is uploaded and before the user is asked to approve, so a refusal costs nothing and means the file was never read." + ], + "remote": [ + "Cheapest route first: list_app_builds or list_team_app_builds, if the build was uploaded before.", + "Then upload_app_build, if the build sits at a public https link \u2014 the only route that needs nothing from the user.", + "Otherwise the user uploads it themselves. The asset-upload helper PRINTS a curl command and nothing more \u2014 it reads no file and sends no request \u2014 so the user runs that command, and it answers with the bs:// url directly from /app-automate/upload. There is no intermediate storage and no second step.", + "Take the bs:// url back from them and pass it as the app for create_session." + ] + }, + "body": [ + { + "name": "file", + "type": "file", + "required": true, + "description": "Path to the build on this machine, inside MCP_UPLOAD_BASE_DIR.", + "example": "/Users/me/builds/app-release.apk" + }, + { + "name": "custom_id", + "type": "string", + "maxLength": 100, + "description": "Stable name for the build, reusable in place of the bs:// URL.", + "example": "my-app-staging" + } + ], + "returns": [ + "app_url", + "custom_id", + "shareable_id" + ], + "responses": { + "200": { + "description": "The uploaded build.", + "schema": { + "$schema": "AppUpload" + } + }, + "401": { + "$response": "Unauthorized" + } + } + }, + { + "name": "find_elements", + "method": "POST", + "path": "/wd/hub/session/{session_id}/elements", + "mode": "read", + "entity": "app_session", + "intent": "Find every element matching a selector \u2014 use this to count matches, work through a list, or check whether something is present at all.", + "guidance": [ + "No match is an EMPTY LIST and a 200, where find_element answers 404. That makes this the way to test whether something is there without handling an error.", + "An empty list can be trusted to mean absent: a strategy this surface does not accept is refused before the request is sent, and an iOS strategy aimed at an Android screen comes back as a 400 \"invalid selector\" rather than as nothing found.", + "Each entry carries the same element-6066-11e4-a52e-4f735466cecf key as find_element, and the ids go stale when the screen changes just as readily.", + "The order is the UI tree's own, so it follows the screen's layout rather than anything meaningful to the app.", + "Use find_element when exactly one match is expected: its 404 says plainly that the screen is not what you thought." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + } + ], + "body": [ + { + "name": "using", + "type": "string", + "required": true, + "values": [ + "id", + "accessibility id", + "xpath", + "class name", + "-android uiautomator", + "-ios predicate string", + "-ios class chain" + ], + "example": "accessibility id" + }, + { + "name": "value", + "type": "string", + "required": true, + "description": "The selector itself, interpreted according to using.", + "example": "cart-button" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "Every matching element, possibly none.", + "schema": { + "$schema": "ElementListResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_text", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/text", + "mode": "read", + "entity": "app_session", + "intent": "Read the visible text of an element on the screen \u2014 use this to check a label, a title, a price, a field's current contents or an error message without reading the whole screen.", + "guidance": [ + "THIS IS HOW TO READ THE SCREEN: find_element gives the id, this gives what it says \u2014 one line per call, nothing to wade through.", + "On Android it is the text of the view; a container answers with an empty string, not its children's text, so aim at the TextView itself (class name android.widget.TextView, or the element by its own text).", + "On iOS it is the element's label or value \u2014 a text field answers with what it currently holds, or its placeholder when empty.", + "To confirm an action did something, read the same element before and after, or find the text you expect with find_element by text and treat \"no such element\" as the answer." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element or find_elements.", + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The element's visible text.", + "schema": { + "$schema": "StringValueResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_rect", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/rect", + "mode": "read", + "entity": "app_session", + "intent": "Get an element's position and size on the screen \u2014 use this to aim a swipe, drag or tap with perform_actions, or to tell whether something sits under the keyboard.", + "guidance": [ + "The coordinates are in the same space perform_actions uses: device pixels on Android, points on iOS \u2014 so x + width / 2 and y + height / 2 is the element's centre, ready to press.", + "One call per element: the coordinates to aim at, without reading the whole screen.", + "An element that is off screen still has a rect \u2014 one outside get_window_rect's size \u2014 so compare the two before tapping; scroll_gesture or scroll_view brings it on." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element or find_elements.", + "example": "00000000-0000-0001-ffff-ffff00000012" + } + ], + "returns": [ + "value.x", + "value.y", + "value.width", + "value.height" + ], + "responses": { + "200": { + "description": "The element's rectangle on screen.", + "schema": { + "$schema": "RectResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + }, + { + "name": "get_element_attribute", + "method": "GET", + "path": "/wd/hub/session/{session_id}/element/{element_id}/attribute/{name}", + "mode": "read", + "entity": "app_session", + "intent": "Read one attribute of an element \u2014 use this for its resource id, content description, enabled, checked, selected or displayed state, or on iOS its name, label, value or visibility.", + "guidance": [ + "The attribute names are the platform's own: on Android text, content-desc, resource-id, class, package, bounds, checked, checkable, clickable, enabled, focused, selected, displayed, scrollable, long-clickable, password; on iOS name, label, value, type, enabled, visible, accessible, focused, index, rect.", + "Booleans come back as the strings \"true\" and \"false\", not JSON booleans, so compare as text.", + "An attribute the element does not carry answers the STRING \"null\" on Android \u2014 not a JSON null and not an error \u2014 so compare as text; measured on an ImageView's resource-id.", + "For the element's text use get_element_text and for its position get_element_rect; this is for everything else." + ], + "path_params": [ + { + "name": "session_id", + "type": "string", + "required": true, + "example": "0123456789abcdef0123456789abcdef01234567" + }, + { + "name": "element_id", + "type": "string", + "required": true, + "description": "Element id from find_element or find_elements.", + "example": "00000000-0000-0001-ffff-ffff00000012" + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "The attribute to read, as named in the UI tree.", + "example": "content-desc" + } + ], + "returns": [ + "value" + ], + "responses": { + "200": { + "description": "The attribute's value as a string; an absent one is the string \"null\" on Android.", + "schema": { + "$schema": "NullableStringResponse" + } + }, + "404": { + "$response": "WebDriverError" + } + } + } + ], + "schemas": { + "NewSessionResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "description": "The session id every other app_session capability takes." + }, + "capabilities": { + "type": "object", + "description": "Open map of the capabilities the device actually started with; keys are capability names." + } + } + } + } + }, + "StringValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "A string, or null where the platform returns no value - set_orientation answers null on iOS." + } + } + }, + "ElementResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string", + "description": "The element id to pass as element_id." + } + } + } + } + }, + "NullValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "null" + } + } + }, + "AnyValueResponse": { + "type": "object", + "properties": { + "value": { + "description": "Command-specific result: null, a boolean, a string or an object." + } + } + }, + "RectResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + } + } + }, + "WebDriverErrorBody": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "W3C error code such as \"no such element\" or \"invalid session id\"." + }, + "message": { + "type": "string" + } + } + } + } + }, + "BooleanValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "boolean" + } + } + }, + "StringListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "LocationResponse": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, + "altitude": { + "type": "number", + "description": "Often a negligible non-zero value such as 5e-324 rather than exactly 0." + } + } + } + } + }, + "DeviceList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "device": { + "type": "string", + "description": "Device name, to be passed to create_session exactly as given." + }, + "os": { + "type": "string", + "description": "android or ios." + }, + "os_version": { + "type": "string" + }, + "device_tier": { + "type": "string", + "description": "Tier 1 to Tier 4; tiers group devices by how widely used they are, not by capability." + }, + "device_limit": { + "type": "integer", + "description": "How many sessions the account may run on this model at once." + }, + "realMobile": { + "type": "boolean", + "description": "True for a physical device." + } + } + } + }, + "DedicatedDeviceList": { + "type": "array", + "description": "Private devices, returned as the product sends them; rows carry more fields than the public catalogue.", + "items": { + "type": "object", + "properties": { + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + } + } + } + }, + "Plan": { + "type": "object", + "properties": { + "automate_plan": { + "type": "string" + }, + "parallel_sessions_running": { + "type": "integer", + "description": "Account-wide, including sessions other people and CI started." + }, + "parallel_sessions_max_allowed": { + "type": "integer" + }, + "team_parallel_sessions_max_allowed": { + "type": "integer", + "description": "Ceiling shared by the whole team; can be lower than the account maximum." + }, + "queued_sessions": { + "type": "integer" + }, + "queued_sessions_max_allowed": { + "type": "integer" + }, + "terminal_access": { + "type": "string" + } + } + }, + "AppUpload": { + "type": "object", + "properties": { + "app_url": { + "type": "string", + "description": "The bs:// URL create_session installs." + }, + "custom_id": { + "type": "string" + }, + "shareable_id": { + "type": "string", + "description": "username/custom_id form, usable by teammates." + } + } + }, + "AppBuildList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "app_name": { + "type": "string" + }, + "app_version": { + "type": "string" + }, + "app_url": { + "type": "string", + "description": "The bs:// URL create_session takes." + }, + "app_id": { + "type": "string", + "description": "Internal identifier; not accepted by create_session." + }, + "custom_id": { + "type": "string" + }, + "shareable_id": { + "type": "string" + }, + "uploaded_at": { + "type": "string" + } + } + } + }, + "AppDetails": { + "type": "object", + "properties": { + "app_name": { + "type": "string" + }, + "app_version": { + "type": "string" + }, + "app_url": { + "type": "string" + }, + "app_custom_id": { + "type": "string" + }, + "app_filename": { + "type": "string" + }, + "uploaded_at": { + "type": "string" + } + } + }, + "SessionStatusUpdate": { + "type": "object", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + } + } + } + }, + "description": "The session record after the change, wrapped in automation_session." + }, + "SessionDetails": { + "type": "object", + "properties": { + "automation_session": { + "type": "object", + "properties": { + "hashed_id": { + "type": "string", + "description": "The session id." + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "description": "passed, failed, or absent when never set." + }, + "reason": { + "type": "string" + }, + "device": { + "type": "string" + }, + "os": { + "type": "string" + }, + "os_version": { + "type": "string" + }, + "build_name": { + "type": "string" + }, + "project_name": { + "type": "string" + }, + "duration": { + "type": "integer", + "description": "Seconds; null while the session is still running." + }, + "created_at": { + "type": "string" + }, + "public_url": { + "type": "string", + "description": "Shareable link that needs no login; it carries an auth token, so treat it as a secret." + }, + "video_url": { + "type": "string", + "description": "Complete only once the session has ended." + }, + "browserstack_status": { + "type": "string", + "description": "What BrowserStack observed, which can differ from the status that was set." + }, + "app_details": { + "$schema": "AppDetails" + }, + "browser_url": { + "type": "string", + "description": "The session on the dashboard; needs a BrowserStack login." + } + } + } + }, + "description": "The session record, wrapped in automation_session." + }, + "MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "IntegerValueResponse": { + "type": "object", + "properties": { + "value": { + "type": "integer" + } + } + }, + "ActiveAppInfo": { + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": { + "bundleId": { + "type": "string" + }, + "pid": { + "type": "integer", + "description": "Process id; a web view context name is built from it." + }, + "name": { + "type": "string", + "description": "Often empty." + } + } + } + } + }, + "ElementListResponse": { + "type": "object", + "properties": { + "value": { + "type": "array", + "items": { + "type": "object", + "properties": { + "element-6066-11e4-a52e-4f735466cecf": { + "type": "string", + "description": "The element id to pass as element_id." + } + } + } + } + } + }, + "SessionDeletedResponse": { + "type": "object", + "description": "What the hub returns when a session is closed. Not the W3C null: state says whether it closed and value is an empty string.", + "properties": { + "state": { + "type": "string", + "description": "\"success\" when the session was closed." + }, + "status": { + "type": "integer", + "description": "The hub's own legacy status code; 0 on success." + }, + "value": { + "type": "string", + "description": "Always empty on success." + } + } + }, + "NullableStringResponse": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Null when the attribute is absent." + } + } + } + }, + "responses": { + "WebDriverError": { + "description": "W3C WebDriver error; read value.error for the code.", + "schema": { + "$schema": "WebDriverErrorBody" + } + }, + "Unauthorized": { + "description": "The BrowserStack username or access key was rejected." + } + }, + "paging": {}, + "entities": { + "app_session": { + "title": "App Session", + "description": "A live Appium session on one real device running one installed app, driven command by command until it ends.", + "aliases": [ + "appium session", + "app automate session", + "device session", + "live session", + "webdriver session", + "mobile session", + "test session" + ], + "id_convention": "session_id is a 40-character hex string returned by create_session at value.sessionId; it cannot be listed or guessed. element_id is an opaque string from find_element, valid only until the screen changes.", + "key_facts": [ + "A session holds a real device and consumes device minutes from start until it ends; it ends itself after the idle timeout with no command, or after 2 hours.", + "The app must already be uploaded before a session can start; a build is uploaded by giving a public download link, not by sending a file.", + "Actions are decided from the UI tree: read the page source, act, then read it again to confirm the effect.", + "Each session is recorded with video and logs on the session dashboard, including its passed or failed status.", + "A session's record outlives the session: its status, shareable link and video stay available on the account after the device is released.", + "Apps can be launched, closed and removed during a session, and anything removed comes back only when a later session installs it again." + ], + "capabilities": [ + "create_session", + "get_page_source", + "find_element", + "click_element", + "send_keys", + "perform_actions", + "execute_script", + "get_window_rect", + "delete_session", + "take_screenshot", + "press_back", + "get_orientation", + "set_orientation", + "list_contexts", + "set_context", + "hide_keyboard", + "set_geo_location", + "get_geo_location", + "push_file", + "pull_file", + "press_keycode", + "get_session", + "set_session_status", + "set_session_network", + "get_current_app", + "launch_app", + "terminate_app", + "get_app_state", + "is_app_installed", + "uninstall_app", + "simulate_biometric", + "inject_camera_image", + "annotate_session", + "is_keyboard_shown", + "press_button", + "get_simulated_location", + "get_active_app_info", + "read_device_property", + "find_elements", + "get_element_text", + "get_element_rect", + "get_element_attribute", + "clear_element", + "scroll_gesture", + "dismiss_keyboard_key", + "scroll_view" + ] + }, + "device": { + "title": "Device", + "description": "A real phone or tablet model in the BrowserStack cloud, identified by its name and OS version, that a session can run on.", + "aliases": [ + "device", + "real device", + "phone", + "handset", + "mobile device", + "android device", + "ios device", + "iphone", + "tablet", + "private device", + "dedicated device", + "device cloud" + ], + "id_convention": "A device is addressed by its name and OS version as two strings, exactly as the device list spells them; there is no numeric id and no abbreviation is accepted.", + "key_facts": [ + "Public devices are shared with other customers and are taken for the length of a session, so one can be busy even though the account is entitled to it.", + "Private (dedicated) devices are reserved for the team and are the only ones a device tunnel can be opened on.", + "A device's tier groups it by how widely used the model is, not by what it can do.", + "The catalogue changes as models are added and retired, so a device name held from an earlier run can stop being offered." + ], + "capabilities": [ + "list_devices", + "list_dedicated_devices" + ] + }, + "app_build": { + "title": "App Build", + "description": "An uploaded app binary stored on BrowserStack, addressed by a bs:// URL, that a session installs on the device.", + "aliases": [ + "app", + "app build", + "build", + "binary", + "apk", + "aab", + "ipa", + "app upload", + "uploaded app", + "app url" + ], + "id_convention": "app_url is a bs:// URL of 40 hex characters. A build may also carry a custom_id, a name chosen at upload that always resolves to the newest build under it.", + "key_facts": [ + "A build must be uploaded before any session can install it, either from a public download link or from a file on the machine running this server.", + "Builds expire 30 days after upload, so a bs:// URL kept from an earlier run can stop working.", + "Uploading under a custom id that already exists adds another build rather than replacing the old one, and both stay listed.", + "A build uploaded by one person is visible to the whole team." + ], + "capabilities": [ + "upload_app_build", + "list_app_builds", + "list_team_app_builds", + "get_app_builds_by_custom_id", + "upload_app_build_from_file" + ] + }, + "plan": { + "title": "Plan", + "description": "The account's device-session subscription and how much of its parallel-session allowance is in use.", + "aliases": [ + "plan", + "subscription", + "parallel sessions", + "parallels", + "concurrency", + "quota", + "capacity", + "limits", + "usage" + ], + "id_convention": "There is one plan per account; it is not addressed by an id.", + "key_facts": [ + "Parallel-session counts are account-wide and include sessions started by other people and by CI, not only the caller's.", + "A team can have a lower ceiling than the account as a whole.", + "Starting a session past the limit queues it rather than refusing it, which looks like a slow start." + ], + "capabilities": [ + "get_plan" + ] + } + }, + "hosts": { + "api": "https://api-cloud.browserstack.com" + } + } +} diff --git a/package-lock.json b/package-lock.json index cc220262..a1fb8072 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@browserstack/mcp-server", - "version": "2.2.8", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@browserstack/mcp-server", - "version": "2.2.8", + "version": "2.3.0", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 3600af50..b8dfde5d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@browserstack/mcp-server", - "version": "2.2.8", + "version": "2.3.0", "description": "BrowserStack's Official MCP Server", "mcpName": "io.github.browserstack/mcp-server", "main": "dist/index.js", diff --git a/scripts/check-contract.py b/scripts/check-contract.py index 56240ec1..244adee4 100755 --- a/scripts/check-contract.py +++ b/scripts/check-contract.py @@ -15,8 +15,36 @@ """ import json, sys, collections +# The envelope is three fields and every shipped index gets them right, so nothing checked +# it — until a hand-authored file arrived with `schema_version: "0.1"` and the loader +# refused it. Loading is a MERGE across files, so that one file took every product down +# with it: tm, tra and loadtesting became unavailable because a new index had a typo. Cheap +# to assert, and the failure it prevents is total. +SCHEMA_VERSION = 1 + + +def check_envelope(path, d): + problems = [] + if d.get('schema_version') != SCHEMA_VERSION: + problems.append( + f"schema_version is {json.dumps(d.get('schema_version'))}, and the server reads " + f"{SCHEMA_VERSION} — it will refuse this file AND every other index alongside it") + for field in ('version', 'build_id'): + if not isinstance(d.get(field), str) or not d[field].strip(): + problems.append(f"{field} must be a non-empty string, and is {json.dumps(d.get(field))}") + products = [k for k in d if k not in ('schema_version', 'version', 'build_id')] + if len(products) != 1: + problems.append(f"expected exactly one product key, found {products}") + if problems: + print(f"\n{path}: unloadable envelope") + for problem in problems: + print(f" {problem}") + sys.exit(1) + + def load(p): d = json.load(open(p)) + check_envelope(p, d) return d, d[list(k for k in d if k not in ('schema_version','version','build_id'))[0]] def resolve(tm, node, seen=None, path=''): diff --git a/server.json b/server.json index 42085d92..18c2ca6f 100644 --- a/server.json +++ b/server.json @@ -6,12 +6,12 @@ "url": "https://github.com/browserstack/mcp-server", "source": "github" }, - "version": "2.2.8", + "version": "2.3.0", "packages": [ { "registryType": "npm", "identifier": "@browserstack/mcp-server", - "version": "2.2.8", + "version": "2.3.0", "transport": { "type": "stdio" }, diff --git a/src/tools/applive.ts b/src/tools/applive.ts index a8e159a3..c1829b80 100644 --- a/src/tools/applive.ts +++ b/src/tools/applive.ts @@ -6,6 +6,7 @@ import { startSession } from "./applive-utils/start-session.js"; import logger from "../logger.js"; import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; +import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js"; /** * Launches an App Live Session on BrowserStack. @@ -81,9 +82,19 @@ export default function addAppLiveTools( ) { const tools: Record = {}; + // A Live session is for a PERSON. The agent cannot tap, type, read the screen or take a + // screenshot in it, and every eval where an agent was asked to operate an app and chose + // this tool ended with "my tools can start it but can't tap anything in it". The + // description says so, and names the surface that can — only when that surface is on. + const description = + "Open a manual App Live session for the user to operate by hand; the agent cannot drive it." + + (capabilityRegistryEnabled() + ? " To tap, type or read the app yourself, use a mobiledevicecontrol session via invokeCapability." + : ""); + tools.runAppLiveSession = server.tool( "runAppLiveSession", - "Use this tool when user wants to manually check their app on a particular mobile device using BrowserStack's cloud infrastructure. Can be used to debug crashes, slow performance, etc.", + description, { desiredPhone: z .string() diff --git a/src/tools/capability-registry/bind.ts b/src/tools/capability-registry/bind.ts index ef272344..caf7c4bc 100644 --- a/src/tools/capability-registry/bind.ts +++ b/src/tools/capability-registry/bind.ts @@ -8,6 +8,12 @@ * Grouping removes the collision AND the rename, so a caller sends the spec's own names. */ +import appConfig from "../../config.js"; +import { + APP_BINARY_EXTENSIONS, + MAX_APP_UPLOAD_BYTES, + validateUploadPath, +} from "../../lib/upload-validator.js"; import { InvocationError } from "./index-loader.js"; import { Capability, WireConstraints, WireParam } from "./types.js"; @@ -243,6 +249,44 @@ function coerceType( label = param.name, ): unknown { const expected = param.type; + if (expected === "any") { + // PASSES THROUGH UNTOUCHED, which is the whole point of declaring it. A polymorphic + // parameter reached the string branch and arrived as text: WebDriver's frame id takes a + // number, an element reference or null, and `null` sent as "null" is refused as an + // invalid argument — a failure that reads like the caller chose the wrong frame. + return value; + } + if (expected === "file") { + // NEVER ON THE HOSTED SERVER. `local_only` is what keeps file capabilities off the + // remote surface, and that is a flag an index author has to remember; this is the net + // under it. A hosted server has no business reading its own disk on a caller's behalf, + // whatever the index says. + if (appConfig.REMOTE_MCP) + throw new InvocationError( + `'${label}' is a file on the caller's own machine, which this server cannot read. ` + + "Use the form of this capability that takes a public link, or upload by hand.", + ); + // CHECKED HERE, not at egress: `bind` runs dry before the write-consent gate, so a path + // that does not exist — or a base directory nobody configured — is refused before the + // user is asked to approve an upload that was never going to happen. + // + // The rules are the ones the server already applies to every other upload, so the + // answer does not depend on which surface asked. + const given = String(value ?? "").trim(); + if (!given) + throw new InvocationError(`'${label}' must be a path to a file`); + try { + return validateUploadPath(given, { + allowedExtensions: APP_BINARY_EXTENSIONS, + maxSizeBytes: MAX_APP_UPLOAD_BYTES, + allowedBaseDir: appConfig.UPLOAD_BASE_DIR, + }); + } catch (error) { + throw new InvocationError( + error instanceof Error ? error.message : String(error), + ); + } + } if (expected === "object" || expected === "array") { // An opaque body object is passed through as given: the spec does not describe its // fields, so validating or reshaping it would mean inventing a contract. @@ -312,6 +356,34 @@ function coerceType( return value; } + // A DECLARED STRING IS NOT A LICENCE TO STRINGIFY ANYTHING. + // + // The untyped case above was fixed because `String(value)` destroyed objects the spec had + // declined to describe. A DECLARED string was left stringifying whatever arrived, and it + // is the worse of the two: an object sent for `create_test_case_comment`'s `comment` was + // serialised to the literal text "[object Object]", the API accepted it, the write + // returned 200, and the agent reported success in good faith. tm exposes no comment + // delete, so that text is now permanently on the case. + // + // A 400 teaches the caller what it got wrong. A 200 that stores "[object Object]" teaches + // it nothing and cannot be undone, so refuse here rather than let it reach the wire. + // + // `null` passes through instead of becoming the five characters "null", which no API has + // ever wanted: several string parameters document null as the way to CLEAR a field — + // `test_plan_id` says "send null or '' to unlink" — and stringifying it silently sent a + // value where the caller meant to send an absence. + if (expected === "string") { + if (value === null || value === undefined) return value; + if (typeof value === "object") { + throw new InvocationError( + `'${label}' must be a string, and was ${Array.isArray(value) ? "a list" : "an object"}. ` + + `Serialising it would send the text ` + + `${JSON.stringify(Array.isArray(value) ? "[object Array]" : "[object Object]")} ` + + `rather than its contents, and the call would succeed with that stored.`, + ); + } + } + const text = String(value); if (param.values && param.values.length > 0) { const allowed = param.values.map((v) => String(v)); diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 8258bd6b..6e413041 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -98,16 +98,68 @@ function credentialsFrom(config: BrowserStackConfig): Credentials { * Refusing rather than guessing is deliberate: a guessed host fails as a DNS error or a 404 * that reads like the caller's problem, when it is our missing configuration. */ +/** + * The host a capability named, as opposed to the product's own. + * + * `_HOST_` is in the override variable deliberately. The environment-suffixed form is + * `CAPABILITY_REGISTRY_BASE_URL__`, so a bare + * `CAPABILITY_REGISTRY_BASE_URL_APPAUTOMATE_HUB` would mean the hub host or the "hub" + * environment depending on which the reader had in mind — and whichever the code chose, + * the other reading sends a call to the wrong origin silently. + * + * A named environment refuses rather than falling back to the declared host, for the same + * reason the product-level resolver does: a preprod session must not reach production + * because nobody defined preprod's hub. + */ +function resolveNamedHost( + product: string, + key: string, + host: string, + source: HostSource | undefined, + environment: string | undefined, +): string { + const trim = (url: string) => url.replace(/\/$/, ""); + const suffix = `${key}_HOST_${host.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`; + const override = process.env[`CAPABILITY_REGISTRY_BASE_URL_${suffix}`]; + if (override) return trim(override); + + if (environment) { + throw new InvocationError( + `environment '${environment}' has no '${host}' host for product '${product}'. Set ` + + `CAPABILITY_REGISTRY_BASE_URL_${suffix}.`, + ); + } + + const declared = source?.hosts?.[host]; + if (declared) return trim(declared); + + const known = Object.keys(source?.hosts ?? {}); + throw new InvocationError( + `that capability is served from the '${host}' host, which product '${product}' does ` + + `not declare. ` + + (known.length + ? `Its index defines: ${known.join(", ")}.` + : `Its index defines no named hosts.`) + + ` Set CAPABILITY_REGISTRY_BASE_URL_${suffix} to override.`, + ); +} + export async function resolveBaseUrl( product: string, config: BrowserStackConfig, source?: HostSource, transport?: Transport, + host?: string, ): Promise { const key = product.toUpperCase(); const environment = selectedEnvironment(); const trim = (url: string) => url.replace(/\/$/, ""); + // A capability that names a host is asking for one fixed origin, so none of the rungs + // below apply: discovery answers with the product's own host, and the declared + // `base_url` cannot serve this capability's paths at all. + if (host) return resolveNamedHost(product, key, host, source, environment); + const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`]; if (explicit) return trim(explicit); diff --git a/src/tools/capability-registry/discovery.ts b/src/tools/capability-registry/discovery.ts index 04a0ea60..b05ca50f 100644 --- a/src/tools/capability-registry/discovery.ts +++ b/src/tools/capability-registry/discovery.ts @@ -21,7 +21,7 @@ import { Capability, ProductIndex } from "./types.js"; /** What the resolver needs from a product's index to find its host. */ export type HostSource = Pick< ProductIndex, - "base_url" | "base_urls" | "probe_path" | "auth" + "base_url" | "base_urls" | "hosts" | "probe_path" | "auth" > & { capabilities?: Capability[]; }; diff --git a/src/tools/capability-registry/egress.ts b/src/tools/capability-registry/egress.ts index 8b056bc4..e154d5e7 100644 --- a/src/tools/capability-registry/egress.ts +++ b/src/tools/capability-registry/egress.ts @@ -40,8 +40,34 @@ export type Transport = ( headers: Record, query: Record, body?: unknown, + options?: { timeoutMs?: number }, ) => Promise; +/** + * What a cut-off read is told, as opposed to a refused connection. + * + * Both used to be "the product could not be reached", and for a request that was SENT + * that is the wrong advice: a build upload or a device allocation cut off after the last + * byte may well have taken effect, and a caller told it was unreachable retries — and + * uploads twice, or holds two devices. + */ +export function timedOutMessage(timeoutMs: number): string { + return ( + `no answer within ${Math.round(timeoutMs / 1000)}s. The request was sent, so it may ` + + "have taken effect — check (list_app_builds, get_session, or the dashboard) before " + + "retrying." + ); +} + +/** The reply had started arriving and then failed: the request was certainly sent. */ +export function replyCutOffMessage(detail: string): string { + return ( + `the reply was cut off while being read (${detail}). The request was sent, so it ` + + "may have taken effect — check (list_app_builds, get_session, or the dashboard) " + + "before retrying." + ); +} + /** What this server can put into a credential template. Nothing else is fillable. */ const PLACEHOLDERS = ["username", "access_key"] as const; @@ -199,7 +225,20 @@ async function readBody(response: Response): Promise { return ``; } - const raw = await response.text().catch(() => ""); + // A FAILED READ IS NOT AN EMPTY BODY. `fetch` resolves as soon as the headers arrive, so + // the abort timer can fire while the body is still streaming. Swallowing that here + // returned the status line with no payload — a slow session create came back as a 200 + // carrying null, which reads as a successful start that forgot to mention the session id. + // The device was really allocated, so the caller then had a session it could not name and + // could not release. Re-throwing lets the transport report the call as failed. + let raw: string; + try { + raw = await response.text(); + } catch (error) { + throw new Error( + `the response body could not be read: ${error instanceof Error ? error.message : String(error)}`, + ); + } if (!raw.trim()) return null; if (contentType.includes("json")) { @@ -220,8 +259,9 @@ function truncate(text: string): string { } /** A fetch-based transport. Redirects are NOT followed. */ -export function fetchTransport(timeoutMs = 45_000): Transport { - return async (method, url, headers, query, body) => { +export function fetchTransport(defaultTimeoutMs = 45_000): Transport { + return async (method, url, headers, query, body, options) => { + const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs; const target = new URL(url); for (const [key, value] of Object.entries(query || {})) { if (value !== undefined && value !== null) @@ -229,29 +269,53 @@ export function fetchTransport(timeoutMs = 45_000): Transport { } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + // Set once `fetch` resolves, i.e. the status line has arrived: from then on any failure + // — the abort timer firing mid-body, a dropped connection — is a reply that was cut off, + // not a product that could not be reached, and the advice differs. + let replied = false; try { const response = await fetch(target.toString(), { method, headers, // Only send a body when there IS one: a literal `null` payload with a JSON - // content-type is rejected by several endpoints. - body: body === undefined ? undefined : JSON.stringify(body), + // content-type is rejected by several endpoints. A FormData goes through as it is — + // stringifying it yields "[object FormData]", which uploads nothing and reads as a + // product error rather than ours. + body: + body === undefined + ? undefined + : body instanceof FormData + ? body + : JSON.stringify(body), // A redirect from an authenticated API is usually a login bounce, and following it // turns a clear 401/302 into a 200 carrying an HTML sign-in page — which the // resolver would then read as an empty result set rather than a failure. redirect: "manual", signal: controller.signal, }); + replied = true; return { status: response.status, body: await readBody(response), }; - } catch { - // Upstream detail stays out of the reply; the resolver treats status 0 as a failed call. + } catch (error) { + // Upstream detail stays out of the reply; the resolver treats status 0 as a failed + // call. A cut-off is told apart from a refused connection, because the advice + // differs — and a failure after the reply started is a cut-off whatever its class, + // since `readBody` wraps the abort in a plain Error. + const timedOut = error instanceof Error && error.name === "AbortError"; return { status: 0, body: null, - error: "the product could not be reached", + error: replied + ? replyCutOffMessage( + timedOut + ? `no answer within ${Math.round(timeoutMs / 1000)}s` + : "connection lost", + ) + : timedOut + ? timedOutMessage(timeoutMs) + : "the product could not be reached", }; } finally { clearTimeout(timer); diff --git a/src/tools/capability-registry/fallback.ts b/src/tools/capability-registry/fallback.ts new file mode 100644 index 00000000..bb676f33 --- /dev/null +++ b/src/tools/capability-registry/fallback.ts @@ -0,0 +1,97 @@ +/** + * Try the next thing when the product refuses, as the index says to. + * + * ONE REQUEST, ONE RESPONSE is still the rule for every capability that declares nothing + * here. A `fallbacks` list is the one authored exception: when THIS call answers a given + * status, the index knows a sibling capability that may do the job, and names it with the + * body to send. The chain exists for hide_keyboard on an iPhone, where the plain call + * answers 400 because the keyboard has no dismiss key, and the only way on is to press one + * of its own keys — Done where it exists, Return where it does not. + * + * The caller's consent covers the chain: it was given for the capability invoked, and the + * chain is that capability's published contract. What a step may do — press Return, which + * an app may treat as submit — is therefore the capability's guidance to state, not + * something this module decides. It does refuse two things no guidance could make right: a + * step naming a destructive capability, and one naming a capability this surface will not + * call. + * + * Every attempt is reported, in order, with the status it got, so the caller knows which + * step did the work and can read the screen back afterwards. + */ + +import { GroupedArguments } from "./bind.js"; +import { InvocationError } from "./index-loader.js"; +import { InvokeResult } from "./resolve.js"; +import { Capability } from "./types.js"; + +export interface Attempt { + capability: string; + status: number; + /** The authored body the step sent, so the caller can see which keys were tried. */ + body?: Record; +} + +export interface FallbackOutcome { + /** The last response: the first 2xx, or the final refusal. */ + result: InvokeResult; + /** Present only when the chain ran; the first entry is the original call. */ + attempts?: Attempt[]; +} + +/** Makes one request for one capability — `invoke`, with the deps already bound. */ +export type Runner = ( + capability: Capability, + args: GroupedArguments, +) => Promise; + +/** Resolves a step's name within the product of the capability being invoked. */ +export type Lookup = (name: string) => Capability; + +function handle(capability: Capability): string { + return capability.name || `${capability.method} ${capability.path}`; +} + +export async function invokeWithFallbacks( + capability: Capability, + args: GroupedArguments, + run: Runner, + lookup: Lookup, +): Promise { + let result = await run(capability, args); + const chain = capability.fallbacks ?? []; + if (result.ok || chain.length === 0) return { result }; + + const attempts: Attempt[] = [ + { capability: handle(capability), status: result.http_response.status }, + ]; + for (const step of chain) { + // Matched against the LATEST status, not the first: a step that answers something + // the next step does not name ends the chain there, with that answer. + if (result.http_response.status !== step.when.status) continue; + const next = lookup(step.capability); + if (next.mode === "destructive") + throw new InvocationError( + `${handle(capability)} names ${step.capability} as a fallback, which is destructive and cannot run`, + ); + if (next.invocable === false) + throw new InvocationError( + `${handle(capability)} names ${step.capability} as a fallback, which cannot be called on this server`, + ); + // Only the path parameters travel: the step's body and query are the index's own, so + // a caller cannot steer the chain into sending something the contract never showed. + result = await run(next, { + path_params: args.path_params, + query: step.query, + body: step.body, + }); + attempts.push({ + capability: step.capability, + status: result.http_response.status, + ...(step.body ? { body: step.body } : {}), + }); + if (result.ok) break; + } + // A chain that matched nothing is not a chain that ran: the caller sees the plain + // refusal, as it would from a capability that declares no fallbacks at all. + return attempts.length > 1 ? { result, attempts } : { result }; +} diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index d04c76f1..3a7dac90 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -7,6 +7,7 @@ */ import { readFileSync } from "node:fs"; +import appConfig from "../../config.js"; import { basename, dirname } from "node:path"; import { Capability, @@ -24,7 +25,18 @@ import { export class IndexError extends Error {} /** Thrown to the caller as a tool error, so the wording is caller-facing. */ -export class InvocationError extends Error {} +export class InvocationError extends Error { + /** + * Set when the handle exists in the artifact but this surface withholds it. + * + * The MESSAGE deliberately does not say so — a caller is told the capability does not + * exist, because "withheld" invites a retry, a search for synonyms, or a question to the + * user about enabling it, none of which can succeed here. The flag keeps the distinction + * for telemetry, where knowing how often a withheld handle is reached for is how we learn + * whether withholding it was the right call. + */ + withheld?: boolean; +} /** The file inside a product subdirectory, for the earlier nested layout. */ export const INDEX_FILE = "index.json"; @@ -57,9 +69,135 @@ export function endpointKey(method: string, path: string): string { * FAILS CLOSED: any truthy `disabled` hides it, not just `=== true`. The flag exists to * suppress something that cannot work, so an unexpected shape must hide it rather than be * ignored — the opposite default would turn a typo into a silently re-enabled capability. + * + * `local_only` withholds on a remote server only, for a capability that reads the caller's + * own machine: there, the path it would take resolves against the operator's disk instead. + * Read at call time rather than captured, because the remote server builds its config per + * session and a value captured at module load would outlive the deployment it described. */ export function isDisabled(capability: Capability): boolean { - return Boolean(capability.disabled); + if (capability.disabled) return true; + return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP); +} + +/** + * Is this capability published for reference only on this surface? + * + * `local_only` on a remote server. The capability is real and the caller may well be asking + * for exactly it, so hiding it leaves the agent concluding no such route exists. Publishing + * it without the means to call it, and with a note saying what to do instead, answers the + * question it was asked. + */ +export function isReferenceOnly(capability: Capability): boolean { + if (capability.disabled) return false; + return Boolean(capability.local_only) && Boolean(appConfig.REMOTE_MCP); +} + +/** + * The reference form: what it is and what to do instead, and nothing to call it with. + * + * The parameters and response shapes go because publishing them invites the attempt — a + * model handed a body schema will fill it in. `intent` stays, because that is what the + * search matched on, and `mode` stays so a reader can still see what kind of operation it + * would have been. + */ +function asReference(capability: Capability): Capability { + const reference: Capability = { ...capability }; + for (const field of [ + "path_params", + "query", + "body", + "responses", + "returns", + "paginated", + "max_page_size", + "encoding", + ] as const) { + delete reference[field]; + } + // Guidance is NOT touched here. `withSurfaceGuidance` has already folded in + // `remote_guidance` — which is where a reference-only capability says what to do + // instead — and it runs for every capability, not only this branch. + // `withSurfaceGuidance` has already run, so guidance is a plain array here — and for a + // reference-only capability its `remote` half is where "what to do instead" lives. + const named = Array.isArray(capability.guidance) + ? capability.guidance.length > 0 + : false; + return { + ...reference, + invocable: false, + ...(named + ? {} + : { guidance: ["This capability cannot be called on this server."] }), + }; +} + +/** + * Flatten `guidance` to the lines that apply to the surface doing the loading. + * + * A capability may say three things about how to use it: what is true everywhere, what is + * true only where the server sits beside the user, and what is true only on a remote one. + * Only one of the last two can ever apply, so the caller is handed exactly that and never + * has to work out which half was meant for them. + * + * COMPOSED, NOT REPLACED. An earlier shape put the remote instruction in a single + * `unavailable_note` that replaced the guidance outright, which threw away lines still true + * there — the accepted file types, how long a large upload takes — and invited them to be + * restated in the note, where the two copies could drift. Shared lines are now written once. + * + * The object form is flattened away here, so no reader downstream sees anything but a plain + * array, and which surface this is stays ours to know rather than a fact the caller acts on. + */ +/** read < write < destructive; anything the gate does not recognise ranks highest. */ +function modeRank(mode: unknown): number { + return mode === "read" ? 0 : mode === "write" ? 1 : 2; +} + +/** + * Refuse, at load, a fallback chain the runtime would have to refuse on the first call. + * + * `invokeWithFallbacks` already refuses a destructive or uncallable step, but it never + * compared the step's mode with the original's — so a read could have fallen back to a + * write sibling and run it without the consent prompt a write gets. Today's only chain + * (hide_keyboard → dismiss_keyboard_key) is write → write; this keeps the next index from + * opening that door, and reports an unknown target here rather than after a request has + * already gone out. + */ +function checkFallbacks(product: string, capabilities: Capability[]): void { + const byName = new Map(capabilities.map((c) => [c.name, c])); + for (const capability of capabilities) { + for (const step of capability.fallbacks ?? []) { + const target = byName.get(step.capability); + if (!target) + throw new IndexError( + `${product}.${capability.name} falls back to '${step.capability}', which the ` + + "index does not define", + ); + if (target.mode === "destructive") + throw new IndexError( + `${product}.${capability.name} falls back to '${step.capability}', which is destructive`, + ); + if (modeRank(target.mode) > modeRank(capability.mode)) + throw new IndexError( + `${product}.${capability.name} (${capability.mode}) falls back to ` + + `'${step.capability}' (${target.mode}): a fallback may not need more consent ` + + "than the call that triggered it", + ); + } + } +} + +function withSurfaceGuidance(capability: Capability): Capability { + const g = capability.guidance; + if (!g || Array.isArray(g)) return capability; + const lines = [ + ...(g.shared ?? []), + ...(appConfig.REMOTE_MCP ? (g.remote ?? []) : (g.local ?? [])), + ]; + const next: Capability = { ...capability }; + if (lines.length) next.guidance = lines; + else delete next.guidance; + return next; } export interface LoadedProduct { @@ -228,11 +366,26 @@ export class CapabilityRegistry { const withheld = new Map(); const enabled: Capability[] = []; - for (const capability of bundle.capabilities) { + for (let capability of bundle.capabilities) { // UNIQUENESS IS CHECKED OVER EVERY CAPABILITY, disabled ones included. A clash // between a disabled name and a live one is still a broken artifact, and skipping // the disabled half would mean re-enabling the flag could fail the load later, // somewhere with less context than here. + // Surface guidance is folded in for EVERY capability, before any of the branches + // below, so a reference-only one and an ordinary one are composed the same way. + capability = withSurfaceGuidance(capability); + if (isReferenceOnly(capability)) { + // PUBLISHED, BUT NOT CALLABLE. It keeps its name and intent so the search that + // should find it does, and loses everything a caller would need to invoke it. + const reference = asReference(capability); + enabled.push(reference); + lookup.set( + endpointKey(capability.method, capability.path), + reference, + ); + if (capability.name) names.set(capability.name, reference); + continue; + } if (isDisabled(capability)) { withheld.set( endpointKey(capability.method, capability.path), @@ -265,39 +418,42 @@ export class CapabilityRegistry { this.byEndpoint.set(product, lookup); this.byName.set(product, names); this.withheld.set(product, withheld); - products[product] = withheld.size - ? { - ...bundle, - capabilities: enabled, - entities: stripDisabledFromEntities(bundle, withheld), - } - : bundle; + checkFallbacks(product, bundle.capabilities); + // ALWAYS THE PROCESSED PRODUCT. This used to fall back to the raw bundle when nothing + // was disabled, which quietly dropped the per-surface guidance (`withSurfaceGuidance` + // runs on the way into `enabled`) and the reference-only form from search, + // listProducts and describeEntity for any product with no `disabled` entry — exactly + // the first tm/tra export to use `local_only`. + products[product] = { + ...bundle, + capabilities: enabled, + entities: withheld.size + ? stripDisabledFromEntities(bundle, withheld) + : bundle.entities, + }; } this.index = { ...index, products }; } /** - * The `capability_disabled` message for a handle this build withholds, if it is one. + * Does this build withhold the handle? * - * Says THAT it is disabled and nothing about why. The reason is documentation and lives - * with the decision; repeating it here would put four copies of a paragraph in the - * shipped artifact to serve a path the flag itself makes rare. + * WHAT THE CALLER IS TOLD IS THAT IT DOES NOT EXIST, which is why this returns a boolean + * rather than a message. Saying "withheld" describes the artifact rather than the + * surface, and every action it invites — retry, hunt for a synonym, ask the user to turn + * it on — fails here. A capability absent from search, describe and every listing should + * read the same way at invocation as it does everywhere else. * - * Returns undefined for anything else, so the caller falls through to its own - * not-found wording. + * The caller raises its own not-found error and marks it `withheld`, so the distinction + * survives in telemetry without reaching the agent. */ - private withheldMatch(handle: string, product?: string): string | undefined { + private isWithheld(handle: string, product?: string): boolean { for (const [owner, withheld] of this.withheld) { if (product && owner !== product) continue; - if (!withheld.has(handle)) continue; - return ( - `capability_disabled: ${handle} is disabled on this surface. This is ` + - `deliberate — it is withheld by the ${owner} index, not missing from it — so ` + - `retrying will not help.` - ); + if (withheld.has(handle)) return true; } - return undefined; + return false; } /** How many capabilities each product withholds. Startup logging only. */ @@ -402,12 +558,13 @@ export class CapabilityRegistry { if (capability) matches.push({ product: owner, capability }); } if (matches.length === 0) { - // WITHHELD IS A DIFFERENT ANSWER FROM ABSENT, and only here. Search cannot surface a - // disabled capability, so reaching this branch means the caller already held the - // exact handle — from an older index, a doc, or a human. "unknown_capability" would - // send it hunting for a synonym that does not exist; the reason ends the attempt. - const off = this.withheldMatch(name, product); - if (off) throw new InvocationError(off); + // A WITHHELD HANDLE ANSWERS THE SAME WAY AS AN ABSENT ONE. Search, describe and the + // listings already behave as though it does not exist, and invocation is no place to + // contradict them: "withheld" is a fact about the artifact, not about this surface, + // and it invites a retry, a hunt for a synonym, or a question to the user about + // enabling something that cannot be enabled here. The error carries `withheld` so the + // attempt is still countable in telemetry. + const withheldHere = this.isWithheld(name, product); // Say whether names are published at all for the product asked about: "no such name" // and "this product does not name its capabilities" need different fixes. @@ -421,7 +578,9 @@ export class CapabilityRegistry { ? ` ${unnamed.sort().join(", ")} ${unnamed.length > 1 ? "publish" : "publishes"} ` + `no capability names yet — call those by \`method\` and \`path\` instead.` : " Search again — names come from searchCapability and describeEntity."; - throw new InvocationError(`unknown_capability: ${name}.${hint}`); + const error = new InvocationError(`unknown_capability: ${name}.${hint}`); + if (withheldHere) error.withheld = true; + throw error; } if (matches.length > 1 && !product) { const owners = matches @@ -458,12 +617,12 @@ export class CapabilityRegistry { if (capability) matches.push({ product: name, capability }); } if (matches.length === 0) { - const off = this.withheldMatch(key, product); - if (off) throw new InvocationError(off); - throw new InvocationError( + const error = new InvocationError( `unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` + `searchCapability returned them, placeholders included.`, ); + if (this.isWithheld(key, product)) error.withheld = true; + throw error; } if (matches.length > 1 && !product) { const owners = matches diff --git a/src/tools/capability-registry/narrow.ts b/src/tools/capability-registry/narrow.ts new file mode 100644 index 00000000..b3fb4cdc --- /dev/null +++ b/src/tools/capability-registry/narrow.ts @@ -0,0 +1,221 @@ +/** + * Narrowing a response the product will not narrow itself. + * + * invokeCapability makes one request and hands back what came off the wire. That is the + * right default and it stays the default: a caller reasoning about a product's answer + * should be reasoning about the product's answer. This is the declared exception. + * + * It exists because of `list_browsers`. `/automate/browsers.json` returns every operating + * system, browser and version combination the account can run — 5,251 rows and 604 KB — + * and accepts no parameters at all: `?os=Windows`, `?browser=chrome` and `?zzz=1` all come + * back byte-identical to the bare call. Its guidance told the caller to "narrow it + * yourself", which it had no means to do; the only way to use the capability was to not be + * an agent, because 604 KB of JSON is most of a context window spent to learn that Chrome + * runs on Windows 11. + * + * WHAT KEEPS THIS FROM BECOMING A SECOND API. Three rules, and they are the whole design: + * + * 1. A capability must declare `narrowable` before any of this is reachable. Nothing is + * applied to a capability that did not ask for it, so the untouched-response promise + * is intact everywhere it was intact before. + * 2. Only the fields it names may be used, and a field outside that set is refused BY + * NAME with the set listed — the same way an unknown host is refused. A caller that + * guesses `platform` learns what the fields are instead of silently getting everything. + * 3. The result says it was narrowed, from how many rows, and by what. A smaller answer + * that does not announce itself is indistinguishable from a product that returned less + * than it has, which is the failure this must not introduce. + * + * It is NOT a query language and should not grow into one. Equality, membership, distinct + * tuples, projection, a cap. Anything a caller cannot express here is a reason to look for + * a product endpoint that filters, not a reason to add an operator. + */ + +import { Capability } from "./types.js"; +import { InvocationError } from "./index-loader.js"; + +/** What the caller asked for. Every field is optional; an empty selection narrows nothing. */ +export interface Selection { + /** + * Field → the value it must take, or any of several. + * + * Strings compare case-insensitively. The product's own spellings are not consistent — + * `Windows` and `OS X` are capitalised, `android` and `ios` are not, in the same column + * of the same response — so an exact match would refuse half the corpus on a technicality + * the caller cannot see from the field name. + */ + where?: Record; + /** Return one row per distinct combination of these fields, and nothing else. */ + distinct?: string[]; + /** Keep only these fields on each row. */ + fields?: string[]; + /** At most this many rows, applied last. */ + limit?: number; +} + +export interface Narrowing { + /** Rows the product sent. */ + from: number; + /** Rows being handed back. */ + to: number; + /** Echoed so the answer carries the question — a narrowed body is not self-describing. */ + by: Selection; +} + +function isSelectionEmpty(selection: Selection): boolean { + return ( + !selection.where && + !selection.distinct && + !selection.fields && + selection.limit === undefined + ); +} + +/** The array inside a body, by the capability's declared path. */ +function rowsAt( + body: unknown, + path: string | undefined, +): unknown[] | undefined { + let here: unknown = body; + for (const step of (path ?? "").split(".").filter(Boolean)) { + if (typeof here !== "object" || here === null) return undefined; + here = (here as Record)[step]; + } + return Array.isArray(here) ? here : undefined; +} + +/** The body with its array replaced, leaving every sibling key as the product sent it. */ +function withRows( + body: unknown, + path: string | undefined, + rows: unknown[], +): unknown { + const steps = (path ?? "").split(".").filter(Boolean); + if (steps.length === 0) return rows; + const clone: Record = { + ...(body as Record), + }; + let here = clone; + for (const step of steps.slice(0, -1)) { + here[step] = { ...(here[step] as Record) }; + here = here[step] as Record; + } + here[steps[steps.length - 1]] = rows; + return clone; +} + +function sameValue(cell: unknown, wanted: unknown): boolean { + if (typeof cell === "string" && typeof wanted === "string") + return cell.toLowerCase() === wanted.toLowerCase(); + // `real_mobile` is true or null, and a caller who writes "true" means true. + if (typeof cell === "boolean" && typeof wanted === "string") + return String(cell) === wanted.toLowerCase(); + if (cell === null || cell === undefined) + return wanted === null || wanted === "null"; + return cell === wanted; +} + +function assertKnownFields( + used: string[], + allowed: string[], + what: string, +): void { + const unknown = used.filter((field) => !allowed.includes(field)); + if (unknown.length > 0) + throw new InvocationError( + `${what} names ${unknown.map((f) => `'${f}'`).join(", ")}, which this capability ` + + `cannot be narrowed on. The fields it accepts are: ${allowed.join(", ")}.`, + ); +} + +/** + * Apply a selection to a response, or refuse it. + * + * Returns `undefined` when there is nothing to do — no selection, or a capability that + * declares none — so the caller hands back the original body and the default holds. + */ +/** + * Refuse a `select` the capability cannot honour — BEFORE anything is sent. + * + * This used to run only on the way back, inside `narrowResponse`, so a `create_session` + * carrying a bad `select` really allocated the device and then came back as an error with + * the session id dropped on the floor; the retry allocated a second one. It now runs with + * the other pre-flight checks, ahead of the write-consent prompt, and `narrowResponse` + * calls it again for the no-request path (`select` empty, or body not the declared shape). + */ +export function assertSelectable( + capability: Capability, + selection: Selection | undefined, +): void { + if (!selection || isSelectionEmpty(selection)) return; + const declared = capability.narrowable; + if (!declared) + throw new InvocationError( + "that capability does not support `select`: it returns the product's response as " + + "sent. describeCapability names the ones that do, under `narrowable`.", + ); + const allowed = declared.fields; + assertKnownFields(Object.keys(selection.where ?? {}), allowed, "`where`"); + assertKnownFields(selection.distinct ?? [], allowed, "`distinct`"); + assertKnownFields(selection.fields ?? [], allowed, "`fields`"); +} + +export function narrowResponse( + capability: Capability, + body: unknown, + selection: Selection | undefined, +): { body: unknown; narrowing: Narrowing } | undefined { + if (!selection || isSelectionEmpty(selection)) return undefined; + assertSelectable(capability, selection); + const declared = capability.narrowable!; + + const rows = rowsAt(body, declared.path); + // A body that is not the shape the capability promised is handed back whole. Narrowing + // an error payload, or a response that changed shape, would hide exactly the thing the + // caller needs to see. + if (!rows) return undefined; + + let out = rows; + + for (const [field, wanted] of Object.entries(selection.where ?? {})) { + const wantedValues = Array.isArray(wanted) ? wanted : [wanted]; + out = out.filter((row) => { + const cell = (row as Record)?.[field]; + return wantedValues.some((value) => sameValue(cell, value)); + }); + } + + if (selection.distinct?.length) { + const seen = new Set(); + const unique: unknown[] = []; + for (const row of out) { + const picked = Object.fromEntries( + selection.distinct.map((field) => [ + field, + (row as Record)?.[field], + ]), + ); + const key = JSON.stringify(picked); + if (seen.has(key)) continue; + seen.add(key); + unique.push(picked); + } + out = unique; + } else if (selection.fields?.length) { + out = out.map((row) => + Object.fromEntries( + selection.fields!.map((field) => [ + field, + (row as Record)?.[field], + ]), + ), + ); + } + + if (selection.limit !== undefined) + out = out.slice(0, Math.max(0, selection.limit)); + + return { + body: withRows(body, declared.path, out), + narrowing: { from: rows.length, to: out.length, by: selection }, + }; +} diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index e14942f8..349138ae 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -35,6 +35,8 @@ import { ResponseSelection, resolveResponses, } from "./index-loader.js"; +import { assertSelectable, narrowResponse } from "./narrow.js"; +import { invokeWithFallbacks } from "./fallback.js"; import { invoke } from "./resolve.js"; import { ambiguousProducts, @@ -45,7 +47,7 @@ import { terms, vocabularyOf, } from "./search.js"; -import { Mode } from "./types.js"; +import { Mode, guidanceLines } from "./types.js"; export const PERMISSION_VALUES = ["not_asked", "granted", "denied"] as const; @@ -64,7 +66,7 @@ export interface RegistryDeps { * Per-product base URL. Never baked into the artifact — it is environment AND account * specific: tm is region-sharded, so this is resolved per call, not once at startup. */ - baseUrlFor: (product: string) => Promise; + baseUrlFor: (product: string, host?: string) => Promise; credentialsFor: () => Credentials; transport?: Transport; } @@ -138,8 +140,14 @@ export function addCapabilityRegistryToolsFromConfig( registry, // The whole product bundle, not just its host: a region-sharded product declares // several candidates and the probe needs an endpoint from its own capabilities. - baseUrlFor: (product) => - resolveBaseUrl(product, config, registry.index.products[product]), + baseUrlFor: (product, host) => + resolveBaseUrl( + product, + config, + registry.index.products[product], + undefined, + host, + ), // Read per call, not captured: the remote server rebuilds config per session, so a // captured credential would outlive the session it belongs to. credentialsFor: () => ({ @@ -151,6 +159,19 @@ export function addCapabilityRegistryToolsFromConfig( ); } +/** + * Titles for products whose index does not carry one yet. + * + * The infra indexes are authored here and declare `title`; tm, tra and loadtesting come + * out of the products-os export, which does not emit it yet, and re-formatting three + * 80k-line files to add one key was the wrong trade. These go away when the export does. + */ +const DEFAULT_TITLES: Record = { + tm: "Test Management", + tra: "Test Reporting & Analytics", + loadtesting: "Load Testing", +}; + function ok(payload: unknown): CallToolResult { return { content: [{ type: "text", text: JSON.stringify(payload) }] }; } @@ -228,7 +249,12 @@ export function addCapabilityRegistryTools( * below pin each branch against the message its source actually produces; if you reword * a refusal, one of them will tell you. */ - const invocationReason = (message: string): string => { + const invocationReason = (message: string, error?: unknown): string => { + // A WITHHELD HANDLE IS REPORTED TO THE CALLER AS NOT FOUND, so the message cannot be + // what distinguishes it. The flag on the error can, which keeps the refusal countable + // without the wording admitting the capability exists. + if (error instanceof InvocationError && error.withheld) + return "capability_disabled"; if (message.startsWith("unknown_capability:")) return "unknown_capability"; if (message.startsWith("unknown_endpoint:")) return "unknown_endpoint"; // The outcome this PR turns on: 46 of 244 capabilities are withheld, so this is the @@ -337,13 +363,16 @@ export function addCapabilityRegistryTools( : { verdict: "settled", product: ambiguity.settled }, ) : undefined; + /** The product's title for the question put to a person; the key stays the handle. */ + const label = (name: string) => + registry.index.products[name]?.title ?? DEFAULT_TITLES[name] ?? name; const routing = ambiguity.products.length > 1 ? { verdict: "products_clash" as const, note: `These products clash over ${ambiguity.terms.map((t) => `'${t}'`).join(", ")} — ` + - `the same word means something different in ${ambiguity.products.join(" and ")}. ` + + `the same word means something different in ${ambiguity.products.map(label).join(" and ")}. ` + "ASK THE USER which they mean before searching or invoking anything; do not " + "search each product in turn and merge the results. Searching before you " + "ask will be refused: send the user's answer in `user_words` with this " + @@ -351,9 +380,10 @@ export function addCapabilityRegistryTools( routing_token: listingToken, shared_terms: ambiguity.terms, products: ambiguity.products, - question: `Which product do you mean — ${ambiguity.products.join(" or ")}?`, + question: `Which product do you mean — ${ambiguity.products.map(label).join(" or ")}?`, senses: ambiguity.products.map((name) => ({ product: name, + title: label(name), means: ambiguity.terms .map((term) => { const entities = @@ -390,6 +420,7 @@ export function addCapabilityRegistryTools( routing_token: listingToken, question: `Which product do you mean — ${registry .productNames() + .map(label) .join(" or ")}?`, } : query @@ -415,6 +446,12 @@ export function addCapabilityRegistryTools( ...(routing ? { routing } : {}), products: registry.productNames().map((name) => ({ name, + ...((registry.index.products[name].title ?? DEFAULT_TITLES[name]) + ? { + title: + registry.index.products[name].title ?? DEFAULT_TITLES[name], + } + : {}), summary: registry.index.products[name].summary, // THE ENTITIES AND WHAT EACH ONE IS. Routing is this tool's whole job, and a // product summary alone does not do it: "add a tag to xyz test" reads as @@ -876,8 +913,8 @@ export function addCapabilityRegistryTools( mode: capability.mode, entity: capability.entity, ...(capability.intent ? { intent: capability.intent } : {}), - ...(capability.guidance?.length - ? { guidance: capability.guidance } + ...(guidanceLines(capability.guidance).length + ? { guidance: guidanceLines(capability.guidance) } : {}), })), ...rest, @@ -1002,8 +1039,37 @@ export function addCapabilityRegistryTools( // Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers, // and spreading the capability would leak them through whenever the resolved value // is absent. - const { responses: unresolved, method, path, ...contract } = capability; + // + // THE WIRE FIELDS GO WITH IT, for the same reason the route does: they describe how + // WE reach the product, not anything the caller supplies or acts on. `host` names + // which of the product's origins serves this one capability; `encoding` and + // `send_empty_body` say how the request body is framed; `local_only` and `disabled` + // are claims about which surface publishes it, already answered by the capability + // being present at all. A contract that shows them invites a caller to reason about + // plumbing it cannot influence — and `host: "api"` was doing exactly that in a + // shipped describe response. + // + // `invocable` STAYS, and is the one of these that is not plumbing. Presence says the + // capability is published, which is not the same as callable: a reference-only entry + // is published precisely so search can still find it and say what to do instead. + // Stripping the flag would leave that fact only in a prose guidance line. + const { + responses: unresolved, + method, + path, + host, + encoding, + send_empty_body, + local_only, + disabled, + ...contract + } = capability; void unresolved; + void host; + void encoding; + void send_empty_body; + void local_only; + void disabled; return ok({ // No build_id here either, same reason. `product` stays: describeCapability // resolves by NAME and its product argument is optional, so the answer has to @@ -1027,7 +1093,7 @@ export function addCapabilityRegistryTools( if (error instanceof InvocationError) return refuse( "describeCapability", - invocationReason(error.message), + invocationReason(error.message, error), error.message, undefined, identity, @@ -1049,17 +1115,16 @@ export function addCapabilityRegistryTools( tools.invokeCapability = server.tool( "invokeCapability", - "Call a capability whose contract you have from describeCapability. Pass `name` exactly " + - "as given — that is the handle. Only when a result carries no `name` (some products " + - "do not publish them yet) pass `method` and `path` instead, exactly as returned. " + - "Arguments go in path_params / query / body under the spec's own names. One call " + - "makes exactly one request and returns the product's own response untouched; when " + - "`completed` is false there is another page, which you fetch by sending the " + - "capability's own page parameter. If the mode is 'write' you MUST ask the user " + - "first, then resend with user_permission='granted' and a change_summary; both are " + - "recorded. Capabilities whose mode is 'destructive' (deletes) are refused outright — " + - "archiving, closing and merging are ordinary writes and DO run, so read the mode and " + - "intent before confirming with the user.", + "Call a capability whose contract you have from describeCapability. Pass `name` exactly a" + + "s given — that is the handle; only when a result carries no `name` pass `method` and `pa" + + "th` instead. Arguments go in path_params / query / body under the spec's own names. One " + + "call makes one request and returns the product's response as sent; `select` narrows it o" + + "nly where the contract carries `narrowable`, and reports what it narrowed. When `complet" + + "ed` is false there is another page, fetched with the capability's own page parameter. If" + + " the mode is 'write' you MUST ask the user first, then resend with user_permission='gran" + + "ted' and a change_summary; both are recorded. Capabilities whose mode is 'destructive' a" + + "re refused outright — archiving, closing and merging are ordinary writes and DO run, so " + + "read the mode and intent before confirming with the user.", { name: z .string() @@ -1108,6 +1173,33 @@ export function addCapabilityRegistryTools( .string() .optional() .describe("What will change. Required for writes."), + select: z + .object({ + where: z + .record(z.string(), z.any()) + .optional() + .describe( + "Field -> the value it must take, or an array of accepted values. Strings " + + "match case-insensitively.", + ), + distinct: z + .array(z.string()) + .optional() + .describe( + "Return one row per distinct combination of these fields and nothing else — " + + "the cheapest way to ask what values exist.", + ), + fields: z + .array(z.string()) + .optional() + .describe("Keep only these fields on each row."), + limit: z.number().optional().describe("At most this many rows."), + }) + .optional() + .describe( + "Only for a capability whose contract carries `narrowable`: narrows the response " + + "to those fields and reports what it narrowed.", + ), }, { title: "Invoke Capability", @@ -1165,6 +1257,29 @@ export function addCapabilityRegistryTools( body: input.body, }; + if (capability.invocable === false) { + // THE ONE PLACE describe AND invoke DIVERGE, deliberately. Everywhere else a name + // that describes is a name that invokes, and breaking that is its own bug class — + // but a reference-only capability exists precisely to be read and not called. The + // refusal carries the instruction, because the note is the whole point of it + // still being published. + return refuse( + "invokeCapability", + "capability_not_invocable", + `${handle} cannot be called on this server. ` + + (guidanceLines(capability.guidance)[0] ?? + "Describe it for what to do instead.") + + " Follow that instead of retrying.", + undefined, + identity, + ); + } + + // A `select` the capability cannot honour is refused HERE, with the other + // pre-flight checks: refusing it on the way back meant the request had already + // been sent — a device allocated, a build uploaded — and the answer thrown away. + assertSelectable(capability, input.select); + if (capability.mode === "destructive") { // Refused before binding, so consent is never sought for something that cannot run. return refuse( @@ -1207,15 +1322,42 @@ export function addCapabilityRegistryTools( } } - const result = await invoke( + // The base URL is resolved per target rather than once: a fallback step may sit + // on a different named host from the capability that declared it. + const { result, attempts } = await invokeWithFallbacks( capability, args, - await deps.baseUrlFor(product), - deps.credentialsFor(), - transport, - registry.index.products[product]?.auth, - product, + async (target, targetArgs) => + invoke( + target, + targetArgs, + await deps.baseUrlFor(product, target.host), + deps.credentialsFor(), + transport, + registry.index.products[product]?.auth, + product, + ), + (name) => registry.byNameLookup(name, product).capability, ); + // NARROWING IS APPLIED TO A SUCCESS ONLY. A 4xx body is the diagnosis the caller + // needs whole, and a selection that happened to match none of its keys would + // quietly empty it. `narrowResponse` refuses an unsupported `select` by throwing, + // which lands in the InvocationError branch below with the accepted fields listed. + const narrowed = result.ok + ? narrowResponse(capability, result.http_response.body, input.select) + : undefined; + const answer = { + ...(narrowed + ? { + ...result, + http_response: { ...result.http_response, body: narrowed.body }, + narrowed: narrowed.narrowing, + } + : result), + // Only when the chain ran: a capability without `fallbacks` answers exactly as + // before, and one whose first call succeeded has nothing to report. + ...(attempts ? { fallback: { attempts } } : {}), + }; // The one row for a completed invoke. invoke() returns a 4xx/5xx rather than // throwing, so `success` keeps its tool-level meaning and these two fields carry // whether the product call worked. Status 0 means it could not be reached. @@ -1224,13 +1366,20 @@ export function addCapabilityRegistryTools( upstream_ok: result.ok, upstream_status: result.http_response.status, change_summary: redact(input.change_summary), + ...(attempts ? { fallback_steps: attempts.length - 1 } : {}), + ...(narrowed + ? { + narrowed_from: narrowed.narrowing.from, + narrowed_to: narrowed.narrowing.to, + } + : {}), }); - return ok(result); + return ok(answer); } catch (error) { if (error instanceof InvocationError) return refuse( "invokeCapability", - invocationReason(error.message), + invocationReason(error.message, error), error.message, undefined, identity, diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts index 98e123ef..aaeffcf1 100644 --- a/src/tools/capability-registry/resolve.ts +++ b/src/tools/capability-registry/resolve.ts @@ -18,6 +18,8 @@ import { bind, GroupedArguments } from "./bind.js"; import { authHeaders, Credentials, Transport } from "./egress.js"; import { resolveScope, resolveTokenUrl } from "./oauth.js"; import { AuthScheme } from "./types.js"; +import { openAsBlob } from "node:fs"; +import { basename } from "node:path"; import { InvocationError } from "./index-loader.js"; import { Capability } from "./types.js"; @@ -90,6 +92,32 @@ function assertTransportSafe(baseUrl: string): void { ); } +/** + * Turn a bound body into a FormData, streaming any `file` parameter from disk. + * + * `bind` has already canonicalised and checked the path, so what arrives here is a file + * this server is configured to read. The blob is opened rather than buffered: an app build + * runs to gigabytes and reading one into memory to post it is how a server falls over. + */ +async function multipartBody( + capability: Capability, + body: Record | undefined, +): Promise { + const form = new FormData(); + for (const param of capability.body || []) { + const value = body?.[param.name]; + if (value === undefined || value === null) continue; + if (param.type === "file") { + const path = String(value); + const blob = await openAsBlob(path); + form.append(param.name, blob, basename(path)); + } else { + form.append(param.name, String(value)); + } + } + return form; +} + export async function invoke( capability: Capability, args: GroupedArguments, @@ -106,14 +134,28 @@ export async function invoke( // Before binding would be wrong and after sending is too late: a scheme that mints a // token must not spend a round trip on a call that `bind` was going to reject anyway. const url = `${baseUrl.replace(/\/$/, "")}${bound.path}`; - const send = async () => - transport( - capability.method, - url, - await authHeaders(credentials, auth, product), - bound.query, - bound.body, - ); + const multipart = + capability.encoding === "multipart" + ? await multipartBody(capability, bound.body) + : undefined; + const send = async () => { + const headers = await authHeaders(credentials, auth, product); + if (multipart) { + // fetch writes its own Content-Type with the boundary. Leaving the JSON one on sends + // a body the product cannot parse, under a header that says it should be able to. + delete headers["Content-Type"]; + } + const payload = + multipart ?? + (bound.body === undefined && capability.send_empty_body + ? {} + : bound.body); + return transport(capability.method, url, headers, bound.query, payload, { + timeoutMs: capability.timeout_seconds + ? capability.timeout_seconds * 1000 + : undefined, + }); + }; let response = await send(); diff --git a/src/tools/capability-registry/search.ts b/src/tools/capability-registry/search.ts index 953dccdf..56cc7c7a 100644 --- a/src/tools/capability-registry/search.ts +++ b/src/tools/capability-registry/search.ts @@ -12,7 +12,13 @@ * capability needing an id it cannot possibly have yet. */ -import { Capability, EntityDoc, Mode, ProductIndex } from "./types.js"; +import { + Capability, + EntityDoc, + Mode, + ProductIndex, + guidanceLines, +} from "./types.js"; /** * Every non-alphanumeric character separates, `_` included. @@ -130,11 +136,33 @@ export function isBrowseQuery(query?: string): boolean { return q === "*" || q === "all"; } +/** + * OUR OWN NAME, WHICH IS NOT ROUTING VOCABULARY. + * + * Every product here is BrowserStack's, so the word distinguishes none of them — it is a + * stopword in the strict sense. It could not be spelled as one, because the camelCase + * split above runs first and turns "BrowserStack" into `browser` + `stack`, two words that + * ARE vocabulary: `browser` is a devicecontrol entity and an alias on a tm configuration. + * + * So the company's name claimed a product's noun. "Which Android apps have I uploaded to + * BrowserStack?" clashed over `browser` across four products, and the question put to the + * user — devicecontrol or mobiledevicecontrol or tm or tra — was unanswerable, because no + * word in the request was really about browsers at all. It reached further than queries + * that name us: the tm and tra summaries say "BrowserStack", so those two became claimants + * of `browser` permanently, and "what browsers can I use" clashed four ways on a word only + * devicecontrol models. + * + * Matched after the split and the fold, so one pattern covers "BrowserStack", "browserstack" + * and "browser stack". `bstack` is the same name in the shorthand the logs and repos use. + */ +const BRAND = /\bbrowser\s*stack\b|\bbstack\b/g; + export function terms(text: string | undefined): string[] { return [ ...(text || "") .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .toLowerCase() + .replace(BRAND, " ") .matchAll(WORD), ] .map((match) => match[0]) @@ -407,7 +435,7 @@ function score( // `test_cases_count`); gating it on an identity match instead made a field reachable // only through returns unreachable, which is worse. [(capability.returns || []).join(" "), 1], - [(capability.guidance || []).join(" "), 1], + [guidanceLines(capability.guidance).join(" "), 1], // Parameter names, their descriptions, and their enum values — 330 descriptions and 34 // value lists that the artifact already carries and nothing was reading. The vocabulary // a caller uses is often the value they mean to send: `pass` and `fail` appear nowhere @@ -614,6 +642,25 @@ export interface ProductAmbiguity { * answered the question and there is nothing to ask. Only queries whose every recognised * word is shared are genuinely undecided. */ +/** + * The words that do disclaiming work in a product summary. + * + * Deliberately a list of markers rather than anything resembling negation parsing: the + * sentences this has to catch are written to one pattern ("X is not here", "neither is Y", + * "unlike Test Management", "rather than a browser"), and a looser rule would start + * discarding prose that claims. + */ +const DISCLAIMS = + /\b(?:not|never|neither|nor|unlike|rather than|instead of|except)\b/i; + +/** A summary with its disclaiming sentences removed, for the claimant pass only. */ +function claimingSentences(summary: string | undefined): string { + return (summary ?? "") + .split(/(?<=[.!?])\s+/) + .filter((sentence) => !DISCLAIMS.test(sentence)) + .join(" "); +} + export function ambiguousProducts( products: Record, query: string | undefined, @@ -667,10 +714,21 @@ export function ambiguousProducts( // product already models catches that, and refusing to mine NEW terms out of it is what // keeps the prose from taking over: a summary is a paragraph of verbs and nouns written // to sell the product, and every word in it would otherwise become routing vocabulary. + // + // AND ONLY FROM A SENTENCE THAT CLAIMS. These summaries route deliberately — each one + // carries a sentence saying what is NOT in it and where to go instead, which is the most + // useful sentence in the paragraph and was being read as the opposite of what it says. + // "Browsers are not here — those are device control" made mobiledevicecontrol a claimant + // of `browser`, and "Mobile APPS are not here" made devicecontrol a claimant of `app`. + // Both products were then offered to the user for a word their own summary disowns. + // + // Dropping a sentence can only ever narrow a clash, never misdirect one: prose cannot + // introduce a term, so the worst case is a clash that goes unreported — and a product + // that genuinely handles something does not say so in a negated sentence. const summaries = Object.fromEntries( Object.entries(products).map(([product, index]) => [ product, - ` ${fold(index.summary ?? "")} `, + ` ${fold(claimingSentences(index.summary))} `, ]), ); for (const [key, claimants] of owners) { diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 919076bb..7dafccac 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -118,6 +118,55 @@ export interface ResponseDoc extends ComponentRef { } /** A capability, keyed by the endpoint it exposes. There is deliberately no name. */ +/** + * How to call the endpoint correctly, optionally split by which surface is serving it. + * + * A plain array is shared — true wherever this runs, which is almost everything, and the + * form every shipped index already uses. The object form adds lines that are true on only + * one surface: `local` where the server sits beside the user, `remote` where it does not. + * `shared` is the same list the plain array would have been, so a capability can carry + * facts of both kinds without repeating any of them. + * + * The loader folds the applicable half into one array before anything reads it, so a + * caller is handed exactly the guidance that applies to the server it is talking to and + * never has to work out which half was meant for it. The split itself is ours to know: + * these keys do not survive into what describeCapability or searchCapability return. + * + * WHY A LINE IS SURFACE-SPECIFIC RATHER THAN SHARED: on a remote server, advice about the + * caller's own filesystem is not merely useless but wrong — it would have an agent tell the + * user to configure a machine they are not on. And for a `local_only` capability, `remote` + * is where "what to do instead" lives, which searchCapability puts in front of the caller + * before they pick anything, let alone call it. + */ +export type Guidance = + | string[] + | { shared?: string[]; local?: string[]; remote?: string[] }; + +/** + * The guidance lines as a plain array, whichever form was authored. + * + * The loader flattens the object form at load, so everything downstream genuinely does hold + * an array — this exists so that is checked rather than asserted, and so a capability read + * straight from a file (a test, a checker) behaves the same as one from the registry. + * Without the surface to decide between them, only the shared lines can be returned. + */ +export function guidanceLines(guidance?: Guidance): string[] { + if (!guidance) return []; + return Array.isArray(guidance) ? guidance : (guidance.shared ?? []); +} + +/** One step of a capability's `fallbacks` chain. */ +export interface Fallback { + /** Runs when the latest response carried this status. */ + when: { status: number }; + /** The sibling capability to call, by published name, in the same product. */ + capability: string; + /** The body to send it — authored, never the caller's. */ + body?: Record; + /** Query parameters to send it, if it takes any. */ + query?: Record; +} + export interface Capability { /** * The caller-facing handle — the snake-cased `operationId`, unique within its product. @@ -132,20 +181,31 @@ export interface Capability { name?: string; method: string; path: string; + /** + * Which of the product's named `hosts` serves this one capability, when it is not the + * product's own `base_url`. + * + * A product is normally one host. App Automate is not: the account-level API (devices, + * app builds, plan, session records) answers on one origin and the Appium hub that + * drives the device answers on another, and neither serves the other's paths. Splitting + * them into two products would split one entity too — the session a hub capability + * creates is the session the account-level API reports on — and a caller holding the id + * would have to know it had changed products to use it. + * + * Unset means the product's own host, so every existing index is unaffected. A name that + * `hosts` does not define is refused by name at invocation rather than quietly falling + * back, because the fallback would send the call to a host that cannot serve the path and + * return a 404 that reads like the caller's mistake. + */ + host?: string; mode: Mode; entity: string; path_params?: WireParam[]; query?: WireParam[]; body?: WireParam[]; intent?: string; - /** - * How to call the endpoint correctly. - * - * ABSENT from the released artifact — the export dropped it, and the shape-change note - * does not say whether that was intended. Kept optional and still scored by search so the - * server works either way; nothing may depend on it being present. - */ - guidance?: string[]; + /** How to call it; see `Guidance` for the local/remote split. */ + guidance?: Guidance; /** Allowlisted row fields. Absent when `shape` is "discovered". */ returns?: string[]; /** "discovered" when the product declares no response schema for this operation. */ @@ -171,6 +231,101 @@ export interface Capability { * suppression must not be ignored for arriving in an unexpected shape. */ disabled?: boolean; + /** + * Withheld when the server is not running on the caller's own machine. + * + * Some capabilities only mean anything when the person and the process share a computer: + * one that reads a file the caller names is the clear case. On a remote server that path + * resolves against the OPERATOR's disk, so honouring it would read one user's files on + * another's behalf — the capability does not become unreliable there, it becomes wrong. + * + * Distinct from `disabled`, which withholds a capability everywhere. This one is a claim + * about where it can run, so the same artifact serves both deployments: a local server + * publishes it and a remote one does not. + * + * FAILS CLOSED in the same way, and takes the same `capability_disabled` path at + * invocation, so a caller holding the handle is told the absence is deliberate rather + * than hunting for a synonym. + */ + local_only?: boolean; + + /** + * FALSE when this build publishes the capability for reference but will not call it. + * + * Set by the loader, never authored: it is the surface's answer, not the artifact's. + */ + invocable?: boolean; + /** + * How the request body is encoded. Absent means JSON, which is every capability today. + * + * `multipart` exists for endpoints that take the bytes of a file rather than a reference + * to one. A capability declaring it must also declare a `file` parameter, and should be + * `local_only`: the path names the caller's own machine. + */ + encoding?: "multipart"; + /** + * Send `{}` rather than no body at all, for an endpoint that takes no parameters. + * + * Normally a capability with nothing to send sends nothing, which is right for a REST API + * and wrong for W3C WebDriver: Chrome answers 400 "invalid argument" to a parameterless + * POST with the body omitted and 200 to the same call carrying `{}`. Appium tolerates the + * omission, which is why the identical capability needs this on Automate and not on App + * Automate. + * + * Opt-in per capability rather than a rule for every bodyless POST, because 27 of those + * exist across the shipped indexes and changing what reaches their products is not + * something this can verify. + */ + send_empty_body?: boolean; + /** + * This capability's array response may be narrowed by the server, on these fields. + * + * THE PRODUCT CANNOT DO IT. Declaring this is a statement that the endpoint offers no + * filtering of its own — not that ours is more convenient. `/automate/browsers.json` + * returns 5,251 rows and ignores every query parameter it is given, so a caller asking + * which browsers run on Windows had to receive 604 KB and find out itself. + * + * Where the wire DOES filter, declare the parameters and let the product filter: a + * server-side pass still transfers the whole response and still costs the round trip, + * and it moves the contract off the product and onto us. + * + * Unset — which is every other capability — means `select` is refused outright and the + * response is handed back exactly as sent. + */ + narrowable?: { + /** The only fields `select` may name. Anything else is refused with this list. */ + fields: string[]; + /** + * Dotted path to the array inside the body; omitted when the body IS the array. + * Siblings of the array are preserved, so an envelope survives the narrowing. + */ + path?: string; + /** One line for the contract: why this is here rather than on the wire. */ + note?: string; + }; + /** + * What to try next when the product refuses THIS call, in order. + * + * The one authored exception to one-request-one-response. Each step names a sibling + * capability in the same product and the fixed body to send it; the original call's + * path parameters are carried over, nothing else. A step runs only when the LATEST + * response has the status it names, and the chain stops at the first 2xx. + * + * Exists for hide_keyboard on an iPhone: the keyboard has no dismiss key, so the plain + * call is refused, and the only way on is to press one of the keys it does carry. The + * caller's consent covers the chain, because the chain is this capability's published + * contract — so what a step may do (press Return, which an app may treat as submit) is + * the capability's guidance to state. Every attempt is reported with its status. + */ + fallbacks?: Fallback[]; + /** + * How long to wait for this one capability's answer, in seconds. + * + * The transport's default (45 s) fits a JSON API and not a build upload or a device + * allocation, both of which routinely run longer. A capability that can legitimately + * take minutes says so here; everything else keeps the default. + */ + timeout_seconds?: number; paginated?: boolean; /** The largest page the operation declares. */ max_page_size?: number; @@ -288,6 +443,14 @@ export interface AuthScheme { } export interface ProductIndex { + /** + * The product's name as a person would say it — "Mobile Device Control", not the key. + * + * The key stays the handle every call takes; this is what the routing question and + * the product listing show, because "which do you mean — mobiledevicecontrol or tm?" + * is a question asked in our vocabulary rather than the user's. + */ + title?: string; summary: string; /** * Words that route to this product but are not the name of anything in it. @@ -330,6 +493,18 @@ export interface ProductIndex { * (see `discovery.probePath`); declare it when the derived choice would be wrong. */ probe_path?: string; + /** + * Additional hosts this product is served from, by name, for capabilities that declare + * `host`. + * + * DISTINCT FROM `base_urls`, which is a list of CANDIDATES for one host — probed in turn + * so a region-sharded product lands on the account's own region. These are several hosts + * that all serve the product at once, each addressing a different part of it, and the + * capability says which it needs rather than the server discovering it. + * + * `base_url` remains the default for every capability that names no host. + */ + hosts?: Record; capabilities: Capability[]; entities: Record; paging?: Record; diff --git a/src/tools/live.ts b/src/tools/live.ts index fa1ac4bc..7306a4d8 100644 --- a/src/tools/live.ts +++ b/src/tools/live.ts @@ -5,6 +5,7 @@ import { startBrowserSession } from "./live-utils/start-session.js"; import { PlatformType } from "./live-utils/types.js"; import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; +import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js"; import globalConfig from "../config.js"; // Define the schema shape @@ -124,9 +125,17 @@ export default function addBrowserLiveTools( ) { const tools: Record = {}; + // Same rule as runAppLiveSession: Live is for a person, and an agent that picks it for a + // click-by-locator task ends up reporting that Live cannot click by locator. + const description = + "Open a manual Live browser session for the user to operate by hand; the agent cannot drive it." + + (capabilityRegistryEnabled() + ? " To click, type or read the page yourself, use a devicecontrol session via invokeCapability." + : ""); + tools.runBrowserLiveSession = server.tool( "runBrowserLiveSession", - "Launch a BrowserStack Live session (desktop or mobile).", + description, LiveArgsShape, { title: "Run Browser Live Session", diff --git a/tests/tools/capabilityRegistryBodyRead.test.ts b/tests/tools/capabilityRegistryBodyRead.test.ts new file mode 100644 index 00000000..bf8009a8 --- /dev/null +++ b/tests/tools/capabilityRegistryBodyRead.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchTransport } from "../../src/tools/capability-registry/egress.js"; + +// `fetch` resolves as soon as the response headers arrive, so a slow body can still be +// streaming when the abort timer fires. That failure used to be swallowed and reported as a +// 200 with no payload — which, for a session create that really did allocate a device, reads +// as "started successfully, but there is no session id", leaving a session nobody can release. + +const headers = { + get: (k: string) => (k === "content-type" ? "application/json" : null), +}; + +describe("a response whose body cannot be read", () => { + it("is reported as a failed call, not as an empty 200", async () => { + vi.stubGlobal("fetch", async () => ({ + status: 200, + headers, + text: async () => { + throw Object.assign(new Error("The operation was aborted."), { + name: "AbortError", + }); + }, + })); + const result = await fetchTransport()( + "POST", + "https://hub.example/session", + {}, + ); + expect(result.status).toBe(0); + // And the advice is the one for a request that was SENT: the status line arrived, so + // the device may really have been allocated. "Could not be reached" would invite the + // retry that takes a second one. + expect(result.error).toMatch(/cut off while being read/); + expect(result.error).toMatch(/may have taken effect/); + expect(result.error).not.toMatch(/could not be reached/); + expect(result.body).toBeNull(); + expect(result.error).toBeTruthy(); + vi.unstubAllGlobals(); + }); + + it("still treats a genuinely empty body as an empty body", async () => { + // A 204, or any endpoint that answers with nothing, must keep working. + vi.stubGlobal("fetch", async () => ({ + status: 204, + headers, + text: async () => "", + })); + const result = await fetchTransport()( + "DELETE", + "https://hub.example/session/x", + {}, + ); + expect(result.status).toBe(204); + expect(result.body).toBeNull(); + expect(result.error).toBeUndefined(); + vi.unstubAllGlobals(); + }); + + it("passes a normal JSON body through unchanged", async () => { + vi.stubGlobal("fetch", async () => ({ + status: 200, + headers, + text: async () => '{"value":{"sessionId":"abc"}}', + })); + const result = await fetchTransport()( + "POST", + "https://hub.example/session", + {}, + ); + expect(result.status).toBe(200); + expect(result.body).toEqual({ value: { sessionId: "abc" } }); + vi.unstubAllGlobals(); + }); +}); diff --git a/tests/tools/capabilityRegistryDescribeSurface.test.ts b/tests/tools/capabilityRegistryDescribeSurface.test.ts new file mode 100644 index 00000000..6856b906 --- /dev/null +++ b/tests/tools/capabilityRegistryDescribeSurface.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// What describeCapability is allowed to say about a capability. +// +// The contract an agent reads has to be about the call it is going to make. Fields that +// describe how THIS server reaches the product — which origin serves the route, how the +// body is framed, which surface publishes it — are not the caller's to reason about, and +// `host: "api"` reached a shipped describe response before anyone noticed. The handler +// spreads the capability, so every field added to the type lands on that surface by +// default: this file is the test that makes adding one a deliberate act. +// +// The existing reference-only tests read the REGISTRY, so they could not have caught it. +// These go through the registered handler, which is what a caller actually sees. + +/** Everything the handler must strip before answering. */ +const WIRE = [ + "host", + "encoding", + "send_empty_body", + "local_only", + "disabled", + "method", + "path", +] as const; + +const CAPABILITIES = [ + { + name: "list_devices", + method: "GET", + path: "/devices.json", + host: "api", + mode: "read", + entity: "device", + intent: "List the devices this account can use.", + returns: ["device", "os"], + responses: { "200": { description: "ok" } }, + }, + { + name: "start_session", + method: "POST", + path: "/session", + mode: "write", + entity: "session", + send_empty_body: true, + intent: "Start a session.", + }, + { + name: "upload_from_file", + method: "POST", + path: "/upload", + mode: "write", + entity: "build", + encoding: "multipart", + local_only: true, + intent: "Upload a build that is on this machine.", + guidance: { remote: ["Upload it yourself and pass the returned url."] }, + body: [{ name: "file", type: "file", required: true }], + }, +]; + +function index() { + return { + schema_version: 1, + build_id: "test", + products: { + p: { + summary: "s", + base_url: "https://p.example", + hosts: { api: "https://api.example" }, + capabilities: CAPABILITIES, + entities: { + device: { description: "A device", capabilities: ["list_devices"] }, + session: { + description: "A session", + capabilities: ["start_session"], + }, + build: { description: "A build", capabilities: ["upload_from_file"] }, + }, + }, + }, + }; +} + +async function describeHandler(remote: boolean) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ default: { REMOTE_MCP: remote } })); + const { CapabilityRegistry } = + await import("../../src/tools/capability-registry/index-loader.js"); + const { addCapabilityRegistryTools } = + await import("../../src/tools/capability-registry/register.js"); + const handlers: Record = {}; + // The registered-tool seam, not a stand-in for it: whatever the real server would be + // handed is what gets called here. + const server = { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + }; + addCapabilityRegistryTools( + server as never, + { + registry: new CapabilityRegistry(index() as never), + } as never, + ); + return async (name: string) => + JSON.parse( + (await handlers.describeCapability({ name, product: "p" }, {} as never)) + .content[0].text, + ); +} + +describe("describeCapability keeps the plumbing off the contract", () => { + beforeEach(() => vi.resetModules()); + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("answers list_devices without naming the origin that serves it", async () => { + const describe_ = await describeHandler(false); + const doc = await describe_("list_devices"); + // The capability DOES declare a host — the point is that it does not come back. + expect(CAPABILITIES[0].host).toBe("api"); + expect(doc.host).toBeUndefined(); + expect(doc.name).toBe("list_devices"); + expect(doc.intent).toBeTruthy(); + }); + + it("strips every wire field from every capability it publishes", async () => { + const describe_ = await describeHandler(false); + for (const c of CAPABILITIES) { + const doc = await describe_(c.name); + for (const field of WIRE) + expect(doc[field], `${c.name}.${field}`).toBeUndefined(); + } + }); + + it("still says what the caller supplies and reads", async () => { + const describe_ = await describeHandler(false); + const doc = await describe_("list_devices"); + expect(doc.returns).toEqual(["device", "os"]); + expect(doc.responses).toBeDefined(); + expect(doc.mode).toBe("read"); + expect(doc.entity).toBe("device"); + }); + + // The one of these fields that is the caller's business. Presence in the index means + // published; it does not mean callable, and a reference-only entry is published exactly + // so the search that should find it still does. + it("keeps invocable:false, which is the one flag a caller acts on", async () => { + const describe_ = await describeHandler(true); + const doc = await describe_("upload_from_file"); + expect(doc.invocable).toBe(false); + expect(doc.guidance).toEqual([ + "Upload it yourself and pass the returned url.", + ]); + // and it is still stripped of the means to try + expect(doc.body).toBeUndefined(); + expect(doc.encoding).toBeUndefined(); + }); + + it("leaves invocable off a capability that can be called", async () => { + const describe_ = await describeHandler(false); + expect((await describe_("list_devices")).invocable).toBeUndefined(); + expect((await describe_("upload_from_file")).invocable).toBeUndefined(); + }); +}); diff --git a/tests/tools/capabilityRegistryDisabled.test.ts b/tests/tools/capabilityRegistryDisabled.test.ts index 0f36ff26..a3d3f2e4 100644 --- a/tests/tools/capabilityRegistryDisabled.test.ts +++ b/tests/tools/capabilityRegistryDisabled.test.ts @@ -76,9 +76,9 @@ describe("a disabled capability is absent from the loaded model", () => { it("is gone from the entity's capability list, which describeEntity returns wholesale", () => { const registry = registryWith(product()); - expect( - registry.index.products.tm.entities.attachment.capabilities, - ).toEqual(["list_attachments_v1"]); + expect(registry.index.products.tm.entities.attachment.capabilities).toEqual( + ["list_attachments_v1"], + ); }); it("leaves the original bundle untouched rather than mutating the caller's object", () => { @@ -92,21 +92,32 @@ describe("a disabled capability is absent from the loaded model", () => { expect(registryWith(product()).disabledCounts()).toEqual({ tm: 1 }); }); - it("reports nothing, and shares the bundle, when no capability is disabled", () => { - const products = { tm: { summary: "s", capabilities: [LIVE], entities: {} } }; + it("reports nothing, and keeps every capability, when none is disabled", () => { + const products = { + tm: { summary: "s", capabilities: [LIVE], entities: {} }, + }; const registry = registryWith(products); expect(registry.disabledCounts()).toEqual({}); - // Untouched products keep object identity — the filter allocates only where it must. - expect(registry.index.products.tm).toBe(products.tm); + // The product is always the processed one now — the raw bundle used to be shared + // when nothing was disabled, which dropped the per-surface guidance for exactly those + // products — so equality is by content, not identity. + expect(registry.index.products.tm).toEqual(products.tm); + expect(registry.index.products.tm.capabilities.map((c) => c.name)).toEqual([ + LIVE.name, + ]); }); }); describe("searchCapability cannot surface one", () => { it("does not return it for a query whose words it matches best", () => { const registry = registryWith(product()); - const hit = searchCapabilities(registry.index.products, "upload attachments", { - product: "tm", - }); + const hit = searchCapabilities( + registry.index.products, + "upload attachments", + { + product: "tm", + }, + ); expect(hit.hits.map((h) => h.capability.name)).not.toContain( "upload_generic_attachments", ); @@ -135,8 +146,12 @@ describe("searchCapability cannot surface one", () => { }); }); -describe("invokeCapability answers `capability_disabled`, not `unknown`", () => { - it("names the reason when the caller already holds the name", () => { +describe("a withheld handle answers the same way as an absent one", () => { + // Search, describe and every listing already behave as though it does not exist. + // Invocation says the same, because "withheld" is a fact about the artifact rather than + // this surface, and it invites a retry or a request to enable something that cannot be + // enabled here. The distinction survives on the error, for telemetry only. + it("tells a caller holding the name that it does not exist", () => { const registry = registryWith(product()); expect(() => registry.byNameLookup("upload_generic_attachments")).toThrow( InvocationError, @@ -145,12 +160,11 @@ describe("invokeCapability answers `capability_disabled`, not `unknown`", () => registry.byNameLookup("upload_generic_attachments"); } catch (error) { const message = (error as Error).message; - expect(message).toContain("capability_disabled"); - // THAT it is disabled, not why. The reason is documentation and lives with the - // decision, not in four copies inside the shipped artifact. - expect(message).toContain("disabled on this surface"); - // The distinction that matters: withheld by the index, not missing from it. - expect(message).not.toContain("unknown_capability"); + expect(message).toContain("unknown_capability"); + expect(message).not.toContain("disabled"); + expect(message).not.toContain("withheld"); + // Countable without being disclosed. + expect((error as InvocationError).withheld).toBe(true); } }); @@ -163,7 +177,20 @@ describe("invokeCapability answers `capability_disabled`, not `unknown`", () => ); throw new Error("expected a refusal"); } catch (error) { - expect((error as Error).message).toContain("capability_disabled"); + const message = (error as Error).message; + expect(message).toContain("unknown_endpoint"); + expect(message).not.toContain("disabled"); + expect((error as InvocationError).withheld).toBe(true); + } + }); + + it("leaves a genuinely absent name unmarked, so the two are told apart in telemetry", () => { + const registry = registryWith(product()); + try { + registry.byNameLookup("no_such_capability"); + throw new Error("expected a refusal"); + } catch (error) { + expect((error as InvocationError).withheld).toBeUndefined(); } }); @@ -217,13 +244,17 @@ describe("the shipped tm index", () => { const { readFileSync } = await import("node:fs"); const raw = JSON.parse(readFileSync(FILE, "utf8")); const off = new Set( - raw.tm.capabilities.filter((c: any) => c.disabled).map((c: any) => c.name), + raw.tm.capabilities + .filter((c: any) => c.disabled) + .map((c: any) => c.name), ); expect(off.size).toBe(WITHHELD_COUNT); for (const name of FILE_BEARING) expect(off.has(name)).toBe(true); // Every destructive capability is withheld, so the tier is now invisible as well as // refused. Nothing that passed is caught up in it. - const destructive = raw.tm.capabilities.filter((c: any) => c.mode === "destructive"); + const destructive = raw.tm.capabilities.filter( + (c: any) => c.mode === "destructive", + ); expect(destructive.every((c: any) => c.disabled)).toBe(true); }); @@ -273,10 +304,10 @@ describe("the shipped tm index", () => { expect(hits.hits.map((h) => h.capability.name)).not.toContain(name); } - // Still refused by name, with the reason. + // Still refused by name — as not found, the same answer every other surface gives. for (const name of FILE_BEARING) { expect(() => registry.byNameLookup(name, "tm")).toThrow( - /capability_disabled/, + /unknown_capability/, ); } }); diff --git a/tests/tools/capabilityRegistryFallback.test.ts b/tests/tools/capabilityRegistryFallback.test.ts new file mode 100644 index 00000000..0f73fdfa --- /dev/null +++ b/tests/tools/capabilityRegistryFallback.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; + +// A capability may declare `fallbacks`: when its own call answers a named status, a sibling +// is called with an authored body, and the chain stops at the first 2xx. hide_keyboard on an +// iPhone is the case: the plain call is refused because the keyboard has no dismiss key, so +// the index says to press Done-type keys, then Return. These pin the wiring — what is sent, +// in what order, what stops it, and that the caller is told every step. + +const SESSION = "0123456789abcdef0123456789abcdef01234567"; +const DONE = { keys: ["Done", "done", "Hide keyboard", "Dismiss"] }; +const RETURN = { keys: ["Return", "return"] }; + +function index(fallbacks?: unknown[], extra: Record = {}) { + return { + schema_version: 1, + build_id: "test", + products: { + p: { + summary: "s", + base_url: "https://p.example", + capabilities: [ + { + name: "hide_keyboard", + method: "POST", + path: "/session/{session_id}/hide", + mode: "write", + entity: "app_session", + intent: "Close the keyboard.", + path_params: [ + { name: "session_id", type: "string", required: true }, + ], + ...(fallbacks ? { fallbacks } : {}), + ...extra, + }, + { + name: "dismiss_keyboard_key", + method: "POST", + path: "/session/{session_id}/execute", + mode: "write", + entity: "app_session", + intent: "Press one of the keyboard's own keys.", + path_params: [ + { name: "session_id", type: "string", required: true }, + ], + body: [ + { + name: "script", + type: "string", + required: true, + values: ["mobile: hideKeyboard"], + }, + { name: "args", type: "array", required: true }, + ], + }, + { + name: "delete_everything", + method: "DELETE", + path: "/session/{session_id}", + mode: "destructive", + entity: "app_session", + intent: "Gone.", + path_params: [ + { name: "session_id", type: "string", required: true }, + ], + }, + ], + entities: { + app_session: { + description: "A session", + capabilities: [ + "hide_keyboard", + "dismiss_keyboard_key", + "delete_everything", + ], + }, + }, + }, + }, + }; +} + +const CHAIN = [ + { + when: { status: 400 }, + capability: "dismiss_keyboard_key", + body: { script: "mobile: hideKeyboard", args: [DONE] }, + }, + { + when: { status: 400 }, + capability: "dismiss_keyboard_key", + body: { script: "mobile: hideKeyboard", args: [RETURN] }, + }, +]; + +/** A transport that answers each call from a queue and records what it was sent. */ +function harness(statuses: number[], fallbacks?: unknown[]) { + const sent: { url: string; body: unknown }[] = []; + const queue = [...statuses]; + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + } as never, + { + registry: new CapabilityRegistry(index(fallbacks) as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async ( + _m: string, + url: string, + _h: unknown, + _q: unknown, + body: unknown, + ) => { + sent.push({ url, body }); + const status = queue.shift() ?? 599; + return { + status, + body: + status < 300 + ? { value: null } + : { value: { error: "invalid element state" } }, + } as never; + }, + } as never, + ); + const call = async (input: Record) => + JSON.parse( + ( + await handlers.invokeCapability( + { + name: "hide_keyboard", + product: "p", + path_params: { session_id: SESSION }, + user_permission: "granted", + change_summary: "close the keyboard", + ...input, + }, + {} as never, + ) + ).content[0].text, + ); + return { sent, call, handlers }; +} + +describe("a capability with fallbacks", () => { + it("runs the chain when its own call is refused, and stops at the first success", async () => { + const { sent, call } = harness([400, 400, 200], CHAIN); + const out = await call({}); + expect(out.ok).toBe(true); + expect(out.http_response.status).toBe(200); + expect(sent.map((s) => s.url)).toEqual([ + `https://p.example/session/${SESSION}/hide`, + `https://p.example/session/${SESSION}/execute`, + `https://p.example/session/${SESSION}/execute`, + ]); + // the step's body is the index's, carrying the original path parameter only + expect(sent[1].body).toEqual({ + script: "mobile: hideKeyboard", + args: [DONE], + }); + expect(sent[2].body).toEqual({ + script: "mobile: hideKeyboard", + args: [RETURN], + }); + }); + + it("tells the caller every attempt, in order, with what each sent", async () => { + const { call } = harness([400, 400, 200], CHAIN); + const out = await call({}); + expect(out.fallback.attempts).toEqual([ + { capability: "hide_keyboard", status: 400 }, + { + capability: "dismiss_keyboard_key", + status: 400, + body: { script: "mobile: hideKeyboard", args: [DONE] }, + }, + { + capability: "dismiss_keyboard_key", + status: 200, + body: { script: "mobile: hideKeyboard", args: [RETURN] }, + }, + ]); + }); + + it("stops early when a step succeeds, sending nothing more", async () => { + const { sent, call } = harness([400, 200], CHAIN); + const out = await call({}); + expect(out.ok).toBe(true); + expect(sent).toHaveLength(2); + expect(out.fallback.attempts).toHaveLength(2); + }); + + it("hands back the last refusal when every step fails, with the whole chain listed", async () => { + const { sent, call } = harness([400, 400, 400], CHAIN); + const out = await call({}); + expect(out.ok).toBe(false); + expect(out.http_response.status).toBe(400); + expect(sent).toHaveLength(3); + expect(out.fallback.attempts.map((a: any) => a.status)).toEqual([ + 400, 400, 400, + ]); + }); + + it("does not run on a status the chain does not name", async () => { + // A 500 is not "the keyboard has no dismiss key"; pressing keys at it would be a guess. + const { sent, call } = harness([500], CHAIN); + const out = await call({}); + expect(out.ok).toBe(false); + expect(sent).toHaveLength(1); + expect(out.fallback).toBeUndefined(); + }); + + it("ends the chain when a step answers something the next step does not name", async () => { + const { sent, call } = harness([400, 500], CHAIN); + const out = await call({}); + expect(out.http_response.status).toBe(500); + expect(sent).toHaveLength(2); + }); + + it("says nothing about fallbacks when the first call succeeds", async () => { + const { sent, call } = harness([200], CHAIN); + const out = await call({}); + expect(out.ok).toBe(true); + expect(sent).toHaveLength(1); + expect(out.fallback).toBeUndefined(); + }); + + it("still makes exactly one request for a capability that declares none", async () => { + const { sent, call } = harness([400]); + const out = await call({}); + expect(out.ok).toBe(false); + expect(sent).toHaveLength(1); + expect(out.fallback).toBeUndefined(); + }); + + it("refuses, at load, a step that names a destructive capability", () => { + // The loader checks every chain before a request can go out; the runtime guard in + // invokeWithFallbacks stays as the backstop behind it. + expect(() => + harness( + [400, 200], + [{ when: { status: 400 }, capability: "delete_everything" }], + ), + ).toThrow(/delete_everything.*destructive/); + }); + + it("refuses, at load, a step that names a capability the index does not define", () => { + expect(() => + harness( + [400, 200], + [{ when: { status: 400 }, capability: "no_such_thing" }], + ), + ).toThrow(/no_such_thing.*does not define/); + }); +}); + +describe("the contract shows the chain", () => { + it("describeCapability publishes `fallbacks`, so a caller knows what may be pressed", async () => { + const { handlers } = harness([], CHAIN); + const doc = JSON.parse( + ( + await handlers.describeCapability( + { name: "hide_keyboard", product: "p" }, + {} as never, + ) + ).content[0].text, + ); + expect(doc.fallbacks).toHaveLength(2); + expect(doc.fallbacks[1].body.args[0].keys).toContain("Return"); + }); +}); diff --git a/tests/tools/capabilityRegistryFileUpload.test.ts b/tests/tools/capabilityRegistryFileUpload.test.ts new file mode 100644 index 00000000..55b76fe2 --- /dev/null +++ b/tests/tools/capabilityRegistryFileUpload.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// A `file` parameter carries the bytes of a build rather than a reference to one. The path +// is checked inside `bind`, which register.ts runs dry BEFORE the write-consent gate — so a +// misplaced file or an unconfigured base directory is refused before the user is asked to +// approve an upload that was never going to happen. + +const CAPABILITY = { + name: "upload_app_build_from_file", + method: "POST", + path: "/app-automate/upload", + mode: "write", + entity: "app_build", + encoding: "multipart", + local_only: true, + body: [ + { name: "file", type: "file", required: true }, + { name: "custom_id", type: "string" }, + ], +}; + +let base: string; +let apk: string; +let outside: string; + +async function bindWith(uploadBaseDir: string | undefined) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ + default: { UPLOAD_BASE_DIR: uploadBaseDir, REMOTE_MCP: false }, + })); + return import("../../src/tools/capability-registry/bind.js"); +} + +describe("a file parameter", () => { + beforeEach(() => { + base = mkdtempSync(join(tmpdir(), "uploads-")); + apk = join(base, "app-release.apk"); + writeFileSync(apk, "not really an apk"); + const other = mkdtempSync(join(tmpdir(), "elsewhere-")); + outside = join(other, "app-release.apk"); + writeFileSync(outside, "not really an apk"); + }); + afterEach(() => { + vi.doUnmock("../../src/config.js"); + rmSync(base, { recursive: true, force: true }); + }); + + it("resolves a build inside the configured directory to its canonical path", async () => { + const { bind } = await bindWith(base); + const bound = bind(CAPABILITY as never, { body: { file: apk } }); + expect(String((bound.body as Record).file)).toContain( + "app-release.apk", + ); + }); + + it("refuses when MCP_UPLOAD_BASE_DIR is not set, and says to set it", async () => { + // The agent has to be able to tell the user what to do, so the refusal names the + // variable rather than reporting a generic rejection. + const { bind } = await bindWith(undefined); + expect(() => bind(CAPABILITY as never, { body: { file: apk } })).toThrow( + /MCP_UPLOAD_BASE_DIR is not set/, + ); + }); + + it("refuses a build outside the directory, and names the directory it must be in", async () => { + const { bind } = await bindWith(base); + expect(() => + bind(CAPABILITY as never, { body: { file: outside } }), + ).toThrow(/must be located inside/); + }); + + it("refuses an extension that is not an app build", async () => { + const notes = join(base, "notes.txt"); + writeFileSync(notes, "hello"); + const { bind } = await bindWith(base); + expect(() => bind(CAPABILITY as never, { body: { file: notes } })).toThrow( + /extension/, + ); + }); + + it("refuses a path that does not exist", async () => { + const { bind } = await bindWith(base); + expect(() => + bind(CAPABILITY as never, { body: { file: join(base, "gone.apk") } }), + ).toThrow(/File not found/); + }); + + it("refuses a build under a hidden directory", async () => { + // Blocks ~/.ssh and friends even after symlink resolution. + const hidden = join(base, ".secrets"); + mkdirSync(hidden); + const hiddenApk = join(hidden, "app-release.apk"); + writeFileSync(hiddenApk, "x"); + const { bind } = await bindWith(base); + expect(() => + bind(CAPABILITY as never, { body: { file: hiddenApk } }), + ).toThrow(/hidden directory/); + }); + + it("refuses an empty path instead of reading the working directory", async () => { + const { bind } = await bindWith(base); + expect(() => bind(CAPABILITY as never, { body: { file: " " } })).toThrow( + /must be a path to a file/, + ); + }); +}); diff --git a/tests/tools/capabilityRegistryLocalOnly.test.ts b/tests/tools/capabilityRegistryLocalOnly.test.ts new file mode 100644 index 00000000..950c2329 --- /dev/null +++ b/tests/tools/capabilityRegistryLocalOnly.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Some capabilities only mean anything when the person and the process share a computer — +// one that reads a file the caller names is the clear case. On a remote server that path +// resolves against the operator's disk, so the capability is not merely unreliable there, +// it is wrong. `local_only` withholds it on that surface while the same artifact keeps +// publishing it locally. + +const LOCAL = { + name: "read_local_thing", + method: "POST", + path: "/x", + mode: "write", + entity: "e", + local_only: true, +}; +const SHARED = { + name: "read_remote_thing", + method: "GET", + path: "/y", + mode: "read", + entity: "e", +}; +const OFF = { + name: "never_available", + method: "GET", + path: "/z", + mode: "read", + entity: "e", + disabled: true, +}; + +async function loader(remote: boolean) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ default: { REMOTE_MCP: remote } })); + return import("../../src/tools/capability-registry/index-loader.js"); +} + +describe("local_only", () => { + beforeEach(() => vi.resetModules()); + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("is published on a local server", async () => { + const { isDisabled } = await loader(false); + expect(isDisabled(LOCAL as never)).toBe(false); + }); + + it("is withheld on a remote server", async () => { + const { isDisabled } = await loader(true); + expect(isDisabled(LOCAL as never)).toBe(true); + }); + + it("leaves every other capability alone on both surfaces", async () => { + for (const remote of [false, true]) { + const { isDisabled } = await loader(remote); + expect(isDisabled(SHARED as never)).toBe(false); + } + }); + + it("keeps `disabled` withholding everywhere, local included", async () => { + // The two flags are independent: one is about where it can run, the other about + // whether it runs at all. + for (const remote of [false, true]) { + const { isDisabled } = await loader(remote); + expect(isDisabled(OFF as never)).toBe(true); + } + }); + + it("fails closed on a truthy value that is not a boolean", async () => { + // A flag whose only job is suppression must not be ignored for arriving oddly shaped. + const { isDisabled } = await loader(true); + expect(isDisabled({ ...LOCAL, local_only: "yes" } as never)).toBe(true); + }); + + it("does not withhold when the flag is absent, whatever the surface", async () => { + const { isDisabled } = await loader(true); + expect(isDisabled({ ...LOCAL, local_only: undefined } as never)).toBe( + false, + ); + }); +}); diff --git a/tests/tools/capabilityRegistryNamedHost.test.ts b/tests/tools/capabilityRegistryNamedHost.test.ts new file mode 100644 index 00000000..a231ac4a --- /dev/null +++ b/tests/tools/capabilityRegistryNamedHost.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A product is normally one host. App Automate is not: the account-level API (devices, app +// builds, plan, session records) and the Appium hub that drives the device are different +// origins, and neither serves the other's paths. A capability says which it needs through +// `host`, resolved against the product's `hosts` map. + +const CONFIG = { + "browserstack-username": "ing_Xx", + "browserstack-access-key": "SECRET", +} as any; + +const SOURCE = { + base_url: "https://api-cloud.browserstack.com", + hosts: { hub: "https://hub-cloud.browserstack.com" }, +}; + +const OVERRIDE = "CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL_HOST_HUB"; + +describe("a capability served from a named host", () => { + beforeEach(() => { + delete process.env[OVERRIDE]; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL; + delete process.env.CAPABILITY_REGISTRY_ENV; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env[OVERRIDE]; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL; + delete process.env.CAPABILITY_REGISTRY_ENV; + }); + + it("resolves to the host it names, not the product's own", async () => { + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-cloud.browserstack.com"); + }); + + it("still gives the product's own host to a capability that names none", async () => { + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE)).toBe( + "https://api-cloud.browserstack.com", + ); + }); + + it("trims a trailing slash, so the index can be authored either way", async () => { + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl( + "mobiledevicecontrol", + CONFIG, + { ...SOURCE, hosts: { hub: "https://hub-cloud.browserstack.com/" } }, + undefined, + "hub", + ), + ).toBe("https://hub-cloud.browserstack.com"); + }); + + it("lets config override one named host without touching the others", async () => { + process.env[OVERRIDE] = "https://hub-preprod.example"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-preprod.example"); + expect(await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE)).toBe( + "https://api-cloud.browserstack.com", + ); + }); + + it("is NOT overridden by the product-level variable, which addresses a different host", async () => { + // Pointing the product at a preprod API must not silently redirect hub traffic there: + // the hub is a separate deployment and would answer 404 for every /wd/hub path. + process.env.CAPABILITY_REGISTRY_BASE_URL_MOBILEDEVICECONTROL = + "https://api-preprod.example"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-cloud.browserstack.com"); + }); + + it("refuses a host the index does not declare, and names the ones it does", async () => { + // Falling back to base_url would send the call to a host that cannot serve the path, + // returning a 404 that reads like the caller's mistake. + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + await expect( + resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "terminal"), + ).rejects.toThrow(/'terminal' host.*does not declare.*hub/s); + }); + + it("refuses a named environment that defines no host for it", async () => { + // The same reason the product-level resolver refuses: a preprod session must not reach + // production because nobody configured preprod's hub. + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + await expect( + resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), + ).rejects.toThrow(/environment 'preprod' has no 'hub' host/); + }); + + it("takes the environment's host from the _HOST_ variable when one is set", async () => { + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + process.env[OVERRIDE] = "https://hub-preprod.example"; + const { resolveBaseUrl } = + await import("../../src/tools/capability-registry/config.js"); + expect( + await resolveBaseUrl("mobiledevicecontrol", CONFIG, SOURCE, undefined, "hub"), + ).toBe("https://hub-preprod.example"); + }); +}); diff --git a/tests/tools/capabilityRegistryNarrow.test.ts b/tests/tools/capabilityRegistryNarrow.test.ts new file mode 100644 index 00000000..6a29af95 --- /dev/null +++ b/tests/tools/capabilityRegistryNarrow.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { narrowResponse } from "../../src/tools/capability-registry/narrow.js"; +import { InvocationError } from "../../src/tools/capability-registry/index-loader.js"; +import { Capability } from "../../src/tools/capability-registry/types.js"; + +// Narrowing exists for one shape of endpoint: one that returns everything and filters +// nothing. These tests pin the three rules that keep it from becoming a query language — +// opt-in, fields declared, and the narrowing reported — plus the cases where it must +// decline to touch the body at all. + +const ROWS = [ + { + os: "Windows", + os_version: "11", + browser: "chrome", + browser_version: "131.0", + device: null, + real_mobile: null, + }, + { + os: "Windows", + os_version: "11", + browser: "chrome", + browser_version: "130.0", + device: null, + real_mobile: null, + }, + { + os: "Windows", + os_version: "10", + browser: "firefox", + browser_version: "133.0", + device: null, + real_mobile: null, + }, + { + os: "OS X", + os_version: "Sequoia", + browser: "safari", + browser_version: "18.0", + device: null, + real_mobile: null, + }, + { + os: "ios", + os_version: "26", + browser: "chromium", + browser_version: null, + device: "iPhone 17", + real_mobile: true, + }, +]; + +const FIELDS = [ + "os", + "os_version", + "browser", + "browser_version", + "device", + "real_mobile", +]; + +const browsers = { + name: "list_browsers", + method: "GET", + path: "/automate/browsers.json", + mode: "read", + entity: "browser", + narrowable: { fields: FIELDS }, +} as Capability; + +const plain = { + name: "list_things", + method: "GET", + path: "/things", + mode: "read", + entity: "thing", +} as Capability; + +const rowsOf = (r: ReturnType) => + r!.body as Record[]; + +describe("a capability must opt in", () => { + it("refuses `select` on one that did not", () => { + expect(() => + narrowResponse(plain, ROWS, { where: { os: "Windows" } }), + ).toThrow(InvocationError); + }); + + it("says where the ones that do are named", () => { + expect(() => narrowResponse(plain, ROWS, { limit: 1 })).toThrow( + /describeCapability.*narrowable/s, + ); + }); + + it("does nothing at all without a selection", () => { + expect(narrowResponse(browsers, ROWS, undefined)).toBeUndefined(); + expect(narrowResponse(browsers, ROWS, {})).toBeUndefined(); + }); +}); + +describe("only the declared fields", () => { + for (const [label, selection] of [ + ["where", { where: { platform: "Windows" } }], + ["distinct", { distinct: ["platform"] }], + ["fields", { fields: ["platform"] }], + ] as const) + it(`refuses an undeclared field in ${label}, by name and with the set`, () => { + // A caller that guesses learns the vocabulary instead of silently getting everything. + expect(() => narrowResponse(browsers, ROWS, selection)).toThrow( + /'platform'.*os, os_version, browser, browser_version, device, real_mobile/s, + ); + }); +}); + +describe("what it actually does", () => { + it("filters on equality", () => { + const out = narrowResponse(browsers, ROWS, { + where: { browser: "chrome" }, + })!; + expect(out.narrowing).toEqual({ + from: 5, + to: 2, + by: { where: { browser: "chrome" } }, + }); + expect(rowsOf(out).every((r) => r.browser === "chrome")).toBe(true); + }); + + it("matches a string case-insensitively, because the product's own column is not consistent", () => { + // `Windows` and `OS X` are capitalised, `android` and `ios` are not, in one column. + expect( + narrowResponse(browsers, ROWS, { where: { os: "windows" } })!.narrowing + .to, + ).toBe(3); + expect( + narrowResponse(browsers, ROWS, { where: { os: "IOS" } })!.narrowing.to, + ).toBe(1); + }); + + it("accepts several values for one field", () => { + const out = narrowResponse(browsers, ROWS, { + where: { browser: ["safari", "chromium"] }, + })!; + expect(out.narrowing.to).toBe(2); + }); + + it("reads real_mobile written as a string, which is how it arrives from a model", () => { + expect( + narrowResponse(browsers, ROWS, { where: { real_mobile: "true" } })! + .narrowing.to, + ).toBe(1); + }); + + it("matches a null cell, so `which rows have no device` is askable", () => { + expect( + narrowResponse(browsers, ROWS, { where: { device: null } })!.narrowing.to, + ).toBe(4); + }); + + it("returns distinct combinations and nothing else", () => { + const out = narrowResponse(browsers, ROWS, { + distinct: ["os", "browser"], + })!; + expect(rowsOf(out)).toEqual([ + { os: "Windows", browser: "chrome" }, + { os: "Windows", browser: "firefox" }, + { os: "OS X", browser: "safari" }, + { os: "ios", browser: "chromium" }, + ]); + }); + + it("projects fields without deduplicating", () => { + const out = narrowResponse(browsers, ROWS, { fields: ["browser"] })!; + expect(rowsOf(out)).toHaveLength(5); + expect(rowsOf(out)[0]).toEqual({ browser: "chrome" }); + }); + + it("caps with limit, last", () => { + const out = narrowResponse(browsers, ROWS, { + where: { os: "Windows" }, + limit: 2, + })!; + expect(out.narrowing).toMatchObject({ from: 5, to: 2 }); + }); + + it("combines where and distinct, which is the shape that makes this worth having", () => { + const out = narrowResponse(browsers, ROWS, { + where: { os: "Windows", browser: "chrome" }, + distinct: ["browser_version"], + })!; + expect(rowsOf(out)).toEqual([ + { browser_version: "131.0" }, + { browser_version: "130.0" }, + ]); + }); +}); + +describe("the answer says it was narrowed", () => { + it("reports the row count it started from and the selection used", () => { + const by = { where: { os: "Windows" }, distinct: ["browser"] }; + const out = narrowResponse(browsers, ROWS, by)!; + // A smaller answer that does not announce itself cannot be told apart from a product + // that returned less than it has. + expect(out.narrowing.from).toBe(5); + expect(out.narrowing.to).toBe(2); + expect(out.narrowing.by).toEqual(by); + }); +}); + +describe("bodies it declines to touch", () => { + it("hands back a body that is not an array", () => { + expect( + narrowResponse(browsers, { message: "Unauthorized" }, { limit: 1 }), + ).toBeUndefined(); + }); + + it("finds an array nested under a declared path and keeps its siblings", () => { + const nested = { + ...browsers, + narrowable: { fields: FIELDS, path: "data.rows" }, + } as Capability; + const body = { info: { next: null }, data: { rows: ROWS, total: 5 } }; + const out = narrowResponse(nested, body, { where: { browser: "safari" } })!; + expect(out.narrowing.to).toBe(1); + const got = out.body as typeof body; + expect(got.info).toEqual({ next: null }); + expect(got.data.total).toBe(5); + expect(got.data.rows).toHaveLength(1); + // and the original is untouched + expect(body.data.rows).toHaveLength(5); + }); + + it("hands back a body whose declared path is not there", () => { + const nested = { + ...browsers, + narrowable: { fields: FIELDS, path: "data.rows" }, + } as Capability; + expect(narrowResponse(nested, { data: {} }, { limit: 1 })).toBeUndefined(); + }); +}); diff --git a/tests/tools/capabilityRegistryNarrowInvoke.test.ts b/tests/tools/capabilityRegistryNarrowInvoke.test.ts new file mode 100644 index 00000000..8cf8b5cd --- /dev/null +++ b/tests/tools/capabilityRegistryNarrowInvoke.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; + +// `select` reaching the response, through the registered tool rather than the module it +// calls. The unit tests next door pin the narrowing itself; these pin the wiring — that a +// selection arrives, that the answer reports it, and that an error body is left alone. + +const ROWS = [ + { + os: "Windows", + os_version: "11", + browser: "chrome", + browser_version: "131.0", + device: null, + }, + { + os: "Windows", + os_version: "11", + browser: "firefox", + browser_version: "133.0", + device: null, + }, + { + os: "ios", + os_version: "26", + browser: "chromium", + browser_version: null, + device: "iPhone 17", + }, +]; + +function tools(body: unknown, status = 200) { + const index = { + schema_version: 1, + build_id: "test", + products: { + p: { + summary: "s", + base_url: "https://p.example", + capabilities: [ + { + name: "list_browsers", + method: "GET", + path: "/browsers.json", + mode: "read", + entity: "browser", + intent: "List combinations.", + narrowable: { + fields: [ + "os", + "os_version", + "browser", + "browser_version", + "device", + ], + }, + }, + { + name: "list_plain", + method: "GET", + path: "/plain.json", + mode: "read", + entity: "browser", + intent: "List things, unnarrowable.", + }, + ], + entities: { + browser: { + description: "A combination", + capabilities: ["list_browsers", "list_plain"], + }, + }, + }, + }, + }; + const handlers: Record = {}; + const server = { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + }; + addCapabilityRegistryTools( + server as never, + { + registry: new CapabilityRegistry(index as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async () => ({ status, body }) as never, + } as never, + ); + return handlers; +} + +const call = async (handlers: Record, input: unknown) => + JSON.parse( + (await handlers.invokeCapability(input, {} as never)).content[0].text, + ); + +describe("invokeCapability applies a selection", () => { + it("narrows the body and says so", async () => { + const out = await call(tools(ROWS), { + name: "list_browsers", + product: "p", + select: { where: { os: "windows" }, distinct: ["browser"] }, + }); + expect(out.http_response.body).toEqual([ + { browser: "chrome" }, + { browser: "firefox" }, + ]); + expect(out.narrowed).toEqual({ + from: 3, + to: 2, + by: { where: { os: "windows" }, distinct: ["browser"] }, + }); + }); + + it("leaves the body alone, and says nothing, without a selection", async () => { + const out = await call(tools(ROWS), { + name: "list_browsers", + product: "p", + }); + expect(out.http_response.body).toEqual(ROWS); + expect(out.narrowed).toBeUndefined(); + }); + + it("refuses a selection on a capability that declares none", async () => { + const out = await call(tools(ROWS), { + name: "list_plain", + product: "p", + select: { limit: 1 }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/does not support `select`/); + }); + + it("refuses a field the capability did not declare, naming the set", async () => { + const out = await call(tools(ROWS), { + name: "list_browsers", + product: "p", + select: { where: { platform: "Windows" } }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/'platform'/); + expect(out.error).toMatch(/os, os_version, browser/); + }); + + it("does not narrow an error body, which is the diagnosis", async () => { + // A selection matching none of a 401's keys would quietly empty it. + const out = await call(tools({ message: "Unauthorized" }, 401), { + name: "list_browsers", + product: "p", + select: { where: { browser: "chrome" } }, + }); + expect(out.ok).toBe(false); + expect(out.http_response.body).toEqual({ message: "Unauthorized" }); + expect(out.narrowed).toBeUndefined(); + }); + + it("still makes exactly one request", async () => { + const sent: unknown[] = []; + const index = new CapabilityRegistry({ + schema_version: 1, + build_id: "t", + products: { + p: { + summary: "s", + base_url: "https://p.example", + capabilities: [ + { + name: "list_browsers", + method: "GET", + path: "/browsers.json", + mode: "read", + entity: "browser", + narrowable: { fields: ["browser"] }, + }, + ], + entities: { + browser: { description: "b", capabilities: ["list_browsers"] }, + }, + }, + }, + } as never); + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find( + (a) => typeof a === "function", + ); + return { name: args[0] }; + }, + } as never, + { + registry: index, + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async (...args: unknown[]) => { + sent.push(args); + return { status: 200, body: ROWS } as never; + }, + } as never, + ); + await call(handlers, { + name: "list_browsers", + product: "p", + select: { distinct: ["browser"] }, + }); + expect(sent).toHaveLength(1); + // and nothing from `select` reached the wire, where it would be ignored anyway + expect(JSON.stringify(sent[0])).not.toMatch(/distinct/); + }); +}); + +describe("the contract advertises it", () => { + it("describeCapability publishes `narrowable`, which is the caller's business", async () => { + const handlers = tools(ROWS); + const doc = JSON.parse( + ( + await handlers.describeCapability( + { name: "list_browsers", product: "p" }, + {} as never, + ) + ).content[0].text, + ); + expect(doc.narrowable.fields).toContain("browser"); + expect(doc.host).toBeUndefined(); + }); + + it("says nothing about it on a capability that has none", async () => { + const handlers = tools(ROWS); + const doc = JSON.parse( + ( + await handlers.describeCapability( + { name: "list_plain", product: "p" }, + {} as never, + ) + ).content[0].text, + ); + expect(doc.narrowable).toBeUndefined(); + }); +}); diff --git a/tests/tools/capabilityRegistryProductTitle.test.ts b/tests/tools/capabilityRegistryProductTitle.test.ts new file mode 100644 index 00000000..b39edee3 --- /dev/null +++ b/tests/tools/capabilityRegistryProductTitle.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; + +// The clash question is put to a person, so it names products the way a person would — +// by `title` — while the key stays the handle every other call takes. + +function listProducts(withTitles: boolean) { + const product = (key: string, title: string, entity: string) => ({ + ...(withTitles ? { title } : {}), + summary: `${title}.`, + base_url: "https://p.example", + capabilities: [ + { + name: `list_${entity}s`, + method: "GET", + path: `/${entity}s`, + mode: "read", + entity, + intent: `List ${entity}s.`, + }, + ], + entities: { + [entity]: { + description: `A ${entity} in ${title}`, + aliases: ["session"], + capabilities: [`list_${entity}s`], + }, + }, + }); + const index = { + schema_version: 1, + build_id: "t", + products: { + mobiledevicecontrol: product( + "mobiledevicecontrol", + "Mobile Device Control", + "app_session", + ), + tm: product("tm", "Test Management", "exploratory_session"), + }, + }; + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + } as never, + { + registry: new CapabilityRegistry(index as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async () => ({ status: 200, body: {} }) as never, + } as never, + ); + return async (query: string) => + JSON.parse( + (await handlers.listProducts({ query }, {} as never)).content[0].text, + ); +} + +describe("the routing question names products by title", () => { + it("asks in the user's words when titles exist, and keeps the key as the handle", async () => { + const out = await listProducts(true)("end my session"); + expect(out.routing.verdict).toBe("products_clash"); + expect(out.routing.question).toBe( + "Which product do you mean — Mobile Device Control or Test Management?", + ); + expect(out.routing.products).toEqual(["mobiledevicecontrol", "tm"]); + expect(out.routing.senses.map((s: any) => [s.product, s.title])).toEqual([ + ["mobiledevicecontrol", "Mobile Device Control"], + ["tm", "Test Management"], + ]); + expect(out.products.map((p: any) => p.title)).toEqual([ + "Mobile Device Control", + "Test Management", + ]); + }); + + it("falls back to the key for a product that declares no title, unless a default exists", async () => { + // tm's index does not carry a title yet (the export does not emit one), so the + // server supplies "Test Management"; mobiledevicecontrol has no default and reads as + // its key when the index gives none. + const out = await listProducts(false)("end my session"); + expect(out.routing.question).toBe( + "Which product do you mean — mobiledevicecontrol or Test Management?", + ); + expect(out.products[0].title).toBeUndefined(); + expect(out.products[1].title).toBe("Test Management"); + }); +}); diff --git a/tests/tools/capabilityRegistryReferenceOnly.test.ts b/tests/tools/capabilityRegistryReferenceOnly.test.ts new file mode 100644 index 00000000..605332b6 --- /dev/null +++ b/tests/tools/capabilityRegistryReferenceOnly.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A `local_only` capability is not hidden on a remote server. The query that should find it +// still finds it, because an agent asked to upload a file from the user's machine needs to +// learn the route exists and what replaces it here. What it loses is the means to call it: +// the parameters and response shapes are stripped, and invocation is refused. +// +// This is the one place describe and invoke diverge. Everywhere else a name that describes +// is a name that invokes; a reference-only capability exists precisely to be read, not +// called. + +const LOCAL_ONLY = { + name: "upload_from_file", + method: "POST", + path: "/upload", + mode: "write", + entity: "build", + encoding: "multipart", + local_only: true, + intent: "Upload a build that is on this machine.", + guidance: { + shared: ["Only .apk and .ipa are accepted."], + local: ["The file must sit inside MCP_UPLOAD_BASE_DIR."], + remote: ["Upload it yourself and pass the returned url."], + }, + body: [{ name: "file", type: "file", required: true }], + returns: ["app_url"], + responses: { "200": { description: "ok" } }, + paginated: false, +}; +const ORDINARY = { + name: "upload_from_url", + method: "POST", + path: "/upload-url", + mode: "write", + entity: "build", + body: [{ name: "url", type: "string", required: true }], +}; +const OFF = { + name: "gone", + method: "GET", + path: "/gone", + mode: "read", + entity: "build", + disabled: true, + local_only: true, +}; + +function product(capabilities: unknown[] = [LOCAL_ONLY, ORDINARY, OFF]) { + return { + p: { + summary: "s", + base_url: "https://p.example", + capabilities, + entities: { + build: { + description: "A build", + capabilities: ["upload_from_file", "upload_from_url", "gone"], + }, + }, + }, + }; +} + +async function load(remote: boolean, capabilities?: unknown[]) { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ default: { REMOTE_MCP: remote } })); + const { CapabilityRegistry } = + await import("../../src/tools/capability-registry/index-loader.js"); + return new CapabilityRegistry({ + schema_version: 1, + build_id: "test", + products: product(capabilities), + } as never); +} + +describe("a local_only capability on a remote server", () => { + beforeEach(() => vi.resetModules()); + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("stays in the product listing, so search can still reach it", async () => { + const reg = await load(true); + expect(reg.index.products.p.capabilities.map((c) => c.name)).toContain( + "upload_from_file", + ); + }); + + it("stays named on its entity", async () => { + const reg = await load(true); + expect(reg.index.products.p.entities.build.capabilities).toContain( + "upload_from_file", + ); + }); + + it("keeps the intent the search matched on", async () => { + const reg = await load(true); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.intent).toBe("Upload a build that is on this machine."); + }); + + it("loses everything a caller would need to invoke it", async () => { + // A model handed a body schema fills it in, so the schema goes. + const reg = await load(true); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.body).toBeUndefined(); + expect(capability.responses).toBeUndefined(); + expect(capability.returns).toBeUndefined(); + expect(capability.encoding).toBeUndefined(); + expect(capability.invocable).toBe(false); + }); + + it("gives the remote half, keeps the shared lines, drops the local ones", async () => { + // The whole point of the split: a fact true on both surfaces is written once and still + // reaches a remote caller, while advice about the user's own disk — which here would be + // wrong, not merely useless — does not. + const reg = await load(true); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.guidance).toEqual([ + "Only .apk and .ipa are accepted.", + "Upload it yourself and pass the returned url.", + ]); + }); + + it("gives the local half on a local server, and never the remote one", async () => { + const reg = await load(false); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.guidance).toEqual([ + "Only .apk and .ipa are accepted.", + "The file must sit inside MCP_UPLOAD_BASE_DIR.", + ]); + }); + + it("flattens the object away, so no reader downstream meets it", async () => { + for (const remote of [true, false]) { + const reg = await load(remote); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(Array.isArray(capability.guidance), `remote=${remote}`).toBe(true); + } + }); + + it("says so when nothing at all applies here, rather than saying nothing", async () => { + // No shared lines and no remote ones: the capability would otherwise be published with + // no explanation of why it cannot be called. + const reg = await load(true, [ + { + ...LOCAL_ONLY, + guidance: { local: ["The file must sit inside MCP_UPLOAD_BASE_DIR."] }, + }, + ]); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.guidance?.[0]).toMatch(/cannot be called/); + }); +}); + +describe("the same capability on a local server", () => { + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("is invocable, with its parameters intact", async () => { + const reg = await load(false); + const { capability } = reg.byNameLookup("upload_from_file", "p"); + expect(capability.invocable).toBeUndefined(); + expect(capability.body).toHaveLength(1); + expect((capability.guidance as string[]).join(" ")).toMatch( + /MCP_UPLOAD_BASE_DIR/, + ); + }); +}); + +describe("the flags do not blur into each other", () => { + afterEach(() => vi.doUnmock("../../src/config.js")); + + it("`disabled` still hides a capability outright, local_only or not", async () => { + for (const remote of [false, true]) { + const reg = await load(remote); + expect( + reg.index.products.p.capabilities.map((c) => c.name), + ).not.toContain("gone"); + expect(() => reg.byNameLookup("gone", "p")).toThrow(/unknown_capability/); + } + }); + + it("leaves an ordinary capability untouched on both surfaces", async () => { + for (const remote of [false, true]) { + const reg = await load(remote); + const { capability } = reg.byNameLookup("upload_from_url", "p"); + expect(capability.invocable).toBeUndefined(); + expect(capability.body).toHaveLength(1); + } + }); +}); diff --git a/tests/tools/capabilityRegistryReviewFixes.test.ts b/tests/tools/capabilityRegistryReviewFixes.test.ts new file mode 100644 index 00000000..c3c5f881 --- /dev/null +++ b/tests/tools/capabilityRegistryReviewFixes.test.ts @@ -0,0 +1,309 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { addCapabilityRegistryTools } from "../../src/tools/capability-registry/register.js"; +import { fetchTransport } from "../../src/tools/capability-registry/egress.js"; + +// The five findings from the review of PR #491, each pinned so it cannot come back. + +function product(capabilities: unknown[], extra: Record = {}) { + return { + summary: "s", + base_url: "https://p.example", + capabilities, + entities: { + thing: { + description: "A thing", + capabilities: (capabilities as { name: string }[]).map((c) => c.name), + }, + }, + ...extra, + }; +} + +function harness(index: unknown) { + const sent: unknown[] = []; + const handlers: Record = {}; + addCapabilityRegistryTools( + { + tool: (...args: unknown[]) => { + handlers[args[0] as string] = args.find((a) => typeof a === "function"); + return { name: args[0] }; + }, + } as never, + { + registry: new CapabilityRegistry(index as never), + baseUrlFor: async () => "https://p.example", + credentialsFor: () => ({ username: "u", accessKey: "k" }) as never, + transport: async (...args: unknown[]) => { + sent.push(args); + return { status: 200, body: { value: { sessionId: "abc" } } } as never; + }, + } as never, + ); + const call = async (tool: string, input: unknown) => + JSON.parse((await handlers[tool](input, {} as never)).content[0].text); + return { sent, call }; +} + +describe("M1: a bad `select` is refused before anything is sent", () => { + it("sends nothing for a capability that declares no narrowable", async () => { + const { sent, call } = harness({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "create_session", + method: "POST", + path: "/session", + mode: "write", + entity: "thing", + intent: "Start.", + }, + ]), + }, + }); + const out = await call("invokeCapability", { + name: "create_session", + product: "p", + user_permission: "granted", + change_summary: "start", + select: { limit: 1 }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/does not support `select`/); + expect(sent).toHaveLength(0); + }); + + it("sends nothing for a field the contract does not declare", async () => { + const { sent, call } = harness({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "list_things", + method: "GET", + path: "/things", + mode: "read", + entity: "thing", + intent: "List.", + narrowable: { fields: ["os"] }, + }, + ]), + }, + }); + const out = await call("invokeCapability", { + name: "list_things", + product: "p", + select: { where: { platform: "x" } }, + }); + expect(out.ok).toBe(false); + expect(out.error).toMatch(/'platform'/); + expect(sent).toHaveLength(0); + }); +}); + +describe("M2: a capability may ask for longer, and a cut-off is told apart from unreachable", () => { + it("passes the capability's timeout to the transport", async () => { + const { sent, call } = harness({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "upload", + method: "POST", + path: "/upload", + mode: "write", + entity: "thing", + intent: "Upload.", + timeout_seconds: 600, + }, + { + name: "quick", + method: "GET", + path: "/quick", + mode: "read", + entity: "thing", + intent: "Quick.", + }, + ]), + }, + }); + await call("invokeCapability", { + name: "upload", + product: "p", + user_permission: "granted", + change_summary: "up", + }); + await call("invokeCapability", { name: "quick", product: "p" }); + expect((sent[0] as unknown[])[5]).toEqual({ timeoutMs: 600_000 }); + expect((sent[1] as unknown[])[5]).toEqual({ timeoutMs: undefined }); + }); + + it("says the request may have taken effect when the read was cut off", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation((_url, init) => { + return new Promise((_resolve, reject) => { + (init?.signal as AbortSignal).addEventListener("abort", () => { + const e = new Error("aborted"); + e.name = "AbortError"; + reject(e); + }); + }); + }); + try { + const transport = fetchTransport(10); + const out = await transport("POST", "https://p.example/x", {}, {}, {}); + expect(out.status).toBe(0); + expect(out.error).toMatch(/no answer within 0s/); + expect(out.error).toMatch(/may have taken effect/); + expect(out.error).not.toMatch(/could not be reached/); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("still says unreachable when the connection itself failed", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new TypeError("fetch failed")); + try { + const out = await fetchTransport()("GET", "https://p.example/x", {}, {}); + expect(out.error).toBe("the product could not be reached"); + } finally { + fetchSpy.mockRestore(); + } + }); +}); + +describe("M3: a fallback chain is checked when the index loads", () => { + const index = (fallbackTo: string, targetMode = "write", mode = "read") => ({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "a", + method: "POST", + path: "/a", + mode, + entity: "thing", + intent: "A.", + fallbacks: [{ when: { status: 400 }, capability: fallbackTo }], + }, + { + name: "b", + method: "POST", + path: "/b", + mode: targetMode, + entity: "thing", + intent: "B.", + }, + ]), + }, + }); + it("refuses a read that would fall back to a write", () => { + expect( + () => new CapabilityRegistry(index("b", "write", "read") as never), + ).toThrow(/may not need more consent/); + }); + it("refuses a fallback to a destructive capability", () => { + expect( + () => new CapabilityRegistry(index("b", "destructive", "write") as never), + ).toThrow(/destructive/); + }); + it("refuses a fallback the index does not define", () => { + expect(() => new CapabilityRegistry(index("nope") as never)).toThrow( + /does not define/, + ); + }); + it("accepts write → write, which is the shipped chain", () => { + expect( + () => new CapabilityRegistry(index("b", "write", "write") as never), + ).not.toThrow(); + }); +}); + +describe("M4: the processed product is used even when nothing is disabled", () => { + it("keeps the surface guidance for a product with no withheld capability", () => { + const registry = new CapabilityRegistry({ + schema_version: 1, + build_id: "t", + products: { + p: product([ + { + name: "only", + method: "GET", + path: "/only", + mode: "read", + entity: "thing", + intent: "Only.", + guidance: { + shared: ["both"], + local: ["on a laptop"], + remote: ["on a server"], + }, + }, + ]), + }, + } as never); + const cap = registry.index.products.p.capabilities[0]; + expect(Array.isArray(cap.guidance)).toBe(true); + expect(cap.guidance).toContain("both"); + // one surface line, never both: this process is local (REMOTE_MCP unset) + expect(cap.guidance).toContain("on a laptop"); + expect(cap.guidance).not.toContain("on a server"); + }); +}); + +describe("M5: file parameters are refused on the hosted server", () => { + afterEach(() => { + vi.doUnmock("../../src/config.js"); + vi.resetModules(); + }); + it("refuses before touching the disk, even without local_only", async () => { + vi.resetModules(); + vi.doMock("../../src/config.js", () => ({ + default: { REMOTE_MCP: true, UPLOAD_BASE_DIR: "/tmp" }, + })); + const { bind } = + await import("../../src/tools/capability-registry/bind.js"); + const capability = { + name: "upload_from_file", + method: "POST", + path: "/upload", + mode: "write", + encoding: "multipart", + body: [{ name: "file", type: "file", required: true }], + }; + expect(() => + bind(capability as never, { body: { file: "/tmp/does-not-matter.apk" } }), + ).toThrow(/this server cannot read/); + }); +}); + +describe("titles: products without one in the index still read as a name", () => { + it("uses the default title for tm", async () => { + const { call } = harness({ + schema_version: 1, + build_id: "t", + products: { + tm: product([ + { + name: "list_x", + method: "GET", + path: "/x", + mode: "read", + entity: "thing", + intent: "X.", + }, + ]), + }, + }); + const out = await call("listProducts", {}); + expect(out.products[0].title).toBe("Test Management"); + }); +}); diff --git a/tests/tools/capabilityRoutingAnchors.test.ts b/tests/tools/capabilityRoutingAnchors.test.ts index 6d5b04ed..811503fa 100644 --- a/tests/tools/capabilityRoutingAnchors.test.ts +++ b/tests/tools/capabilityRoutingAnchors.test.ts @@ -1,9 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fileURLToPath } from "node:url"; -import { productAnchors, ambiguousProducts } from "../../src/tools/capability-registry/search.js"; +import { + productAnchors, + ambiguousProducts, + terms, +} from "../../src/tools/capability-registry/search.js"; import { readFileSync } from "node:fs"; -const load = (p: string) => { const j = JSON.parse(readFileSync(`capability/${p}.capability-index.json`, "utf8")); return j[p]; }; +const load = (p: string) => { + const j = JSON.parse( + readFileSync(`capability/${p}.capability-index.json`, "utf8"), + ); + return j[p]; +}; const products = { tm: load("tm"), tra: load("tra") } as any; describe("productAnchors", () => { @@ -26,8 +35,14 @@ describe("productAnchors", () => { }); describe("ambiguity still fires on the shared nouns", () => { - for (const q of ["What did the last report say?", "Show me the runs", "which project"]) - it(q, () => expect(ambiguousProducts(products, q).products.length).toBeGreaterThan(1)); + for (const q of [ + "What did the last report say?", + "Show me the runs", + "which project", + ]) + it(q, () => + expect(ambiguousProducts(products, q).products.length).toBeGreaterThan(1), + ); }); describe("searchCapability withholds the product names", () => { @@ -41,7 +56,9 @@ describe("searchCapability withholds the product names", () => { "src/tools/capability-registry/register.ts", "utf8", ); - const search = src.slice(src.indexOf("tools.searchCapability = server.tool")); + const search = src.slice( + src.indexOf("tools.searchCapability = server.tool"), + ); const arg = search.slice(0, search.indexOf("routing_token:")); expect(arg).toMatch(/product: z\s*\.string\(\)/); expect(arg).not.toMatch(/product: productArg\(\)/); @@ -79,17 +96,20 @@ describe("the user_words gate", () => { }); it("judges the user's words, not the agent's rewrite of them", async () => { - const { ambiguousProducts } = await import( - "../../src/tools/capability-registry/search.js" - ); + const { ambiguousProducts } = + await import("../../src/tools/capability-registry/search.js"); const { readFileSync } = await import("node:fs"); const src = readFileSync( "src/tools/capability-registry/register.ts", "utf8", ); // The paraphrase is what used to be judged, and it is the caller's to choose. - expect(src).toMatch(/const subject = user_words\?\.trim\(\) \? user_words : query;/); - expect(src).toMatch(/ambiguousProducts\(registry\.index\.products, subject\)/); + expect(src).toMatch( + /const subject = user_words\?\.trim\(\) \? user_words : query;/, + ); + expect(src).toMatch( + /ambiguousProducts\(registry\.index\.products, subject\)/, + ); expect(ambiguousProducts).toBeTypeOf("function"); }); @@ -104,7 +124,9 @@ describe("the user_words gate", () => { // nothing to leak between sessions. expect(src).toMatch(/const token = signRouting\(/); expect(src).toMatch(/const routed = readRouting\(routing_token\)/); - expect(src).toMatch(/routed\?\.verdict === "clash" \|\| routed\?\.verdict === "blank"/); + expect(src).toMatch( + /routed\?\.verdict === "clash" \|\| routed\?\.verdict === "blank"/, + ); // AND NOTHING IS REMEMBERED. A set of live tokens and a last-verdict variable are // both per-connection state; under one process serving several sessions the second // lets one client's listing satisfy another client's gate. @@ -122,15 +144,20 @@ describe("a lone decisive word settles, and says so", () => { // caller used to learn only that nothing needed asking — and then searched the other // product, which is the same wrong turn arrived at quietly. it("names the product its words point at, and the words", () => { - const verdict = ambiguousProducts(products, "Show me all datasets in the project"); + const verdict = ambiguousProducts( + products, + "Show me all datasets in the project", + ); expect(verdict.products).toEqual([]); expect(verdict.settled).toBe("tm"); expect(verdict.because).toContain("dataset"); }); it("leaves `settled` unset when the words genuinely clash", () => { - expect(ambiguousProducts(products, "Delete the failed results from this run.").settled) - .toBeUndefined(); + expect( + ambiguousProducts(products, "Delete the failed results from this run.") + .settled, + ).toBeUndefined(); }); }); @@ -148,8 +175,10 @@ describe("a summary claims the words its product really uses", () => { }); it("still lets a word only one product uses decide", () => - expect(ambiguousProducts(products, "Show me all datasets in the project").products) - .toEqual([])); + expect( + ambiguousProducts(products, "Show me all datasets in the project") + .products, + ).toEqual([])); }); describe("a request with no product vocabulary at all", () => { @@ -169,20 +198,28 @@ describe("a request with no product vocabulary at all", () => { }); it("is not claimed for a request whose words DO name something", () => - expect(ambiguousProducts(products, "What did the last report say?").unknown) - .toBeUndefined()); + expect( + ambiguousProducts(products, "What did the last report say?").unknown, + ).toBeUndefined()); }); describe("routing_terms let a product claim its own words", () => { it("gives tra the flakiness vocabulary no index models as an entity", () => { - expect(ambiguousProducts(products, "Which tests are flaky?").unknown).toBeUndefined(); - expect(productAnchors(products, "Which tests are flaky?").products).toEqual(["tra"]); + expect( + ambiguousProducts(products, "Which tests are flaky?").unknown, + ).toBeUndefined(); + expect(productAnchors(products, "Which tests are flaky?").products).toEqual( + ["tra"], + ); }); it("stops an organisational word both products use from settling it", () => { // `workspace` was tm's alone and decided three prompts on its own; both products // have one, so it decides nothing now. - const verdict = ambiguousProducts(products, "Show me all the projects in our workspace."); + const verdict = ambiguousProducts( + products, + "Show me all the projects in our workspace.", + ); expect(verdict.settled).toBeUndefined(); expect(verdict.products).toEqual(["tm", "tra"]); }); @@ -196,7 +233,10 @@ describe("a clash announced by listProducts binds the next search", () => { // words that caused the clash. it("mints a token on the clash and on a blank request", async () => { const { readFileSync } = await import("node:fs"); - const src = readFileSync("src/tools/capability-registry/register.ts", "utf8"); + const src = readFileSync( + "src/tools/capability-registry/register.ts", + "utf8", + ); const listing = src.slice( src.indexOf("tools.listProducts = server.tool("), src.indexOf("tools.searchCapability = server.tool("), @@ -222,7 +262,10 @@ describe("words that point at another product are evidence, not noise", () => { it("guards the mismatch on the product, not merely on settling at all", async () => { const { readFileSync } = await import("node:fs"); - const src = readFileSync("src/tools/capability-registry/register.ts", "utf8"); + const src = readFileSync( + "src/tools/capability-registry/register.ts", + "utf8", + ); expect(src).toMatch( /const mismatch =\s*\n?\s*routable && !!ambiguity\.settled && ambiguity\.settled !== product/, ); @@ -237,3 +280,104 @@ describe("words that point at another product are evidence, not noise", () => { expect(reopened).not.toMatch(/!ambiguity\.settled &&/); }); }); + +// --------------------------------------------------------------------------------------- +// Two ways a product came to claim a word it does not own. Both were found by a cold agent +// run against the published surface, and both produced the same symptom: a clash put to the +// user over a word nothing in the request was about. + +const four = { + devicecontrol: load("devicecontrol"), + mobiledevicecontrol: load("mobiledevicecontrol"), + tm: load("tm"), + tra: load("tra"), +} as any; + +describe("our own name is not routing vocabulary", () => { + // The camelCase split runs before stopword filtering, so "BrowserStack" arrived as + // `browser` + `stack` — and `browser` is a devicecontrol entity and a tm configuration + // alias. The company's name was claiming a product's noun. + for (const spelling of [ + "BrowserStack", + "browserstack", + "browser stack", + "bstack", + ]) + it(`${JSON.stringify(spelling)} yields no routing terms`, () => + expect(terms(`upload a build to ${spelling}`)).toEqual([ + "upload", + "build", + ])); + + it("still reads `browser` when the caller names one alongside us", () => + expect(terms("which browsers can I use on BrowserStack")).toEqual([ + "browsers", + "use", + ])); + + it("leaves a real mention of browsers intact", () => + expect(terms("open a browser")).toContain("browser")); + + it("the reported request no longer clashes over `browser`", () => { + const a = ambiguousProducts( + four, + "Which Android apps have I uploaded to BrowserStack?", + ); + // It may still be ambiguous — `app` is genuinely shared — but not over this. + expect(a.terms).not.toContain("browser"); + expect(a.terms).not.toContain("stack"); + }); + + it("stops a summary that names the company claiming `browser` by doing so", () => { + // The reach of it. tm and tra say "BrowserStack" in their summaries and nothing else + // in either is about browsers, so both became permanent claimants of the word — and + // "what browsers can I use", which names us nowhere, clashed four ways over a noun + // only devicecontrol models. Synthetic, so the two causes stay apart: this product + // set has no disclaiming sentence in it. + const synthetic = { + owner: { + summary: "Drives a browser.", + base_url: "https://a.example", + capabilities: [], + entities: { browser: { description: "A browser", capabilities: [] } }, + }, + bystander: { + summary: "A BrowserStack product about something else entirely.", + base_url: "https://b.example", + capabilities: [], + entities: { widget: { description: "A widget", capabilities: [] } }, + }, + } as any; + const a = ambiguousProducts(synthetic, "what browsers can I use"); + expect(a.products).not.toContain("bystander"); + expect(a.settled).toBe("owner"); + }); +}); + +describe("a summary sentence that disowns a word does not claim it", () => { + // "Browsers are not here — those are device control" and "Mobile APPS are not here: + // those are mobile device control" are the most useful sentences in either summary, and + // the claimant pass was reading each as its own opposite. + it("mobiledevicecontrol does not claim `browser`", () => { + expect( + ambiguousProducts(four, "what browsers can I use").products, + ).not.toContain("mobiledevicecontrol"); + }); + + it("devicecontrol does not claim `app`", () => { + const a = ambiguousProducts(four, "which apps have I uploaded"); + expect(a.terms).toContain("app"); + expect(a.products).not.toContain("devicecontrol"); + }); + + it("leaves the claim that the sentence actually makes", () => { + // Same two summaries, the positive sentences: devicecontrol does drive browsers and + // mobiledevicecontrol does hold app builds. + expect( + ambiguousProducts(four, "what browsers can I use").products, + ).toContain("devicecontrol"); + expect( + ambiguousProducts(four, "which apps have I uploaded").products, + ).toContain("mobiledevicecontrol"); + }); +}); diff --git a/tests/tools/liveToolsPointAtCapabilities.test.ts b/tests/tools/liveToolsPointAtCapabilities.test.ts new file mode 100644 index 00000000..b195f642 --- /dev/null +++ b/tests/tools/liveToolsPointAtCapabilities.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// A Live session is for a person. In the evals, an agent asked to tap, type or click chose +// runAppLiveSession / runBrowserLiveSession over a capability session and then reported +// that Live cannot be driven. The description now says so up front, and names the surface +// that can — but only while that surface is registered, so a server with the registry off +// never advertises tools it does not have. + +function captureDescriptions() { + const described: Record = {}; + const server = { + tool: (name: string, description: string) => { + described[name] = description; + return { name }; + }, + server: { getClientVersion: () => ({ name: "t", version: "0" }) }, + }; + return { server, described }; +} + +describe("the Live tools say what they are for", () => { + beforeEach(() => { + vi.resetModules(); + }); + afterEach(() => { + delete process.env.CAPABILITY_REGISTRY_DISABLED; + }); + + it("call themselves manual and say the agent cannot drive them", async () => { + const { server, described } = captureDescriptions(); + (await import("../../src/tools/applive.js")).default( + server as never, + {} as never, + ); + (await import("../../src/tools/live.js")).default( + server as never, + {} as never, + ); + for (const name of ["runAppLiveSession", "runBrowserLiveSession"]) { + expect(described[name]).toMatch(/manual/); + expect(described[name]).toMatch(/cannot drive it/); + } + }); + + it("point at the capability session while the registry is on", async () => { + const { server, described } = captureDescriptions(); + (await import("../../src/tools/applive.js")).default( + server as never, + {} as never, + ); + (await import("../../src/tools/live.js")).default( + server as never, + {} as never, + ); + expect(described.runAppLiveSession).toMatch(/mobiledevicecontrol/); + expect(described.runBrowserLiveSession).toMatch(/devicecontrol session/); + expect(described.runAppLiveSession).toMatch(/invokeCapability/); + }); + + it("do not name a surface that is not registered", async () => { + process.env.CAPABILITY_REGISTRY_DISABLED = "true"; + const { server, described } = captureDescriptions(); + (await import("../../src/tools/applive.js")).default( + server as never, + {} as never, + ); + (await import("../../src/tools/live.js")).default( + server as never, + {} as never, + ); + expect(described.runAppLiveSession).not.toMatch(/invokeCapability/); + expect(described.runBrowserLiveSession).not.toMatch(/invokeCapability/); + // the manual warning stays regardless + expect(described.runAppLiveSession).toMatch(/cannot drive it/); + }); +});