@browserstack/mcp-server 2.3.0-beta.5 → 2.3.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@
3
3
  "version": "1.0",
4
4
  "build_id": "6782f7e_2026-10-16T13:54:22Z",
5
5
  "mobiledevicecontrol": {
6
- "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: read the UI tree, find and tap elements, 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 on App Automate and consume its minutes. A build must exist before a session starts and can only be uploaded from a public link, never a file on disk \u2014 so 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.",
6
+ "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 on App Automate and consume its minutes. A build must exist before a session starts and can only be uploaded from a public link, never a file on disk \u2014 so 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.",
7
7
  "routing_terms": [
8
8
  "app automate",
9
9
  "appium",
@@ -74,15 +74,19 @@
74
74
  "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.",
75
75
  "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.",
76
76
  "Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.",
77
- "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 App Automate 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 re-read the page source before every action.",
77
+ "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 App Automate 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.",
78
78
  "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.",
79
- "Set appium_version to 2.19.0 for any session that will use execute_script with a `mobile:` command; the default server is 1.x and answers those with a 404 listing the commands it does know.",
79
+ "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.",
80
80
  "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.",
81
81
  "Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.",
82
82
  "device_name and platform_version come from list_devices; get_plan first if sessions are queueing rather than starting.",
83
83
  "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.",
84
84
  "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.",
85
- "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."
85
+ "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.",
86
+ "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.",
87
+ "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.",
88
+ "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.",
89
+ "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."
86
90
  ],
87
91
  "body": [
88
92
  {
@@ -209,15 +213,15 @@
209
213
  "name": "language",
210
214
  "type": "string",
211
215
  "json_path": "/capabilities/alwaysMatch/appium:language",
212
- "description": "Device language code.",
213
- "example": "fr"
216
+ "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.",
217
+ "example": "de"
214
218
  },
215
219
  {
216
220
  "name": "locale",
217
221
  "type": "string",
218
222
  "json_path": "/capabilities/alwaysMatch/appium:locale",
219
- "description": "Device locale.",
220
- "example": "fr_CA"
223
+ "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.",
224
+ "example": "DE"
221
225
  },
222
226
  {
223
227
  "name": "timezone",
@@ -317,11 +321,7 @@
317
321
  "entity": "app_session",
318
322
  "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.",
319
323
  "guidance": [
320
- "Returns the whole hierarchy as one XML string in value, unfiltered. On Android each node carries text, content-desc, resource-id, clickable, scrollable and bounds; on iOS name, label, value and x/y/width/height.",
321
- "Read it again after every action: the tree is a snapshot, and element ids from find_element go stale once the screen changes.",
322
- "Android bounds are \"[x1,y1][x2,y2]\" in device pixels \u2014 the centre of that box is the coordinate to use with perform_actions.",
323
- "A product-specific attribute is the best locator: resource-id for find_element using \"id\", content-desc for \"accessibility id\".",
324
- "Inside a web view this returns the page's HTML instead of the native tree, and a real page runs to hundreds of kilobytes where a native screen is a few. Switch back to NATIVE_APP before reading the tree again, or search the HTML for what you need rather than reading all of it."
324
+ "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."
325
325
  ],
326
326
  "path_params": [
327
327
  {
@@ -345,7 +345,8 @@
345
345
  "404": {
346
346
  "$response": "WebDriverError"
347
347
  }
348
- }
348
+ },
349
+ "disabled": true
349
350
  },
350
351
  {
351
352
  "name": "find_element",
@@ -356,8 +357,8 @@
356
357
  "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.",
357
358
  "guidance": [
358
359
  "The element id comes back under the W3C key value[\"element-6066-11e4-a52e-4f735466cecf\"]; pass that string as element_id.",
359
- "Take the selector from get_page_source rather than guessing: resource-id \u2192 \"id\", content-desc (Android) or name (iOS) \u2192 \"accessibility id\".",
360
- "No match is a 404 with error \"no such element\" \u2014 re-read the page source; the screen may have changed or the element may be off-screen and need a scroll first.",
360
+ "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.",
361
+ "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.",
361
362
  "\"-android uiautomator\" accepts a UiSelector expression, which can scroll an element into view: new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().text(\"Checkout\")).",
362
363
  "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."
363
364
  ],
@@ -417,8 +418,8 @@
417
418
  "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.",
418
419
  "guidance": [
419
420
  "element_id must come from find_element on the current screen; after the screen changes it is stale and fails with \"stale element reference\".",
420
- "To tap something that has no usable locator, tap its bounds centre with perform_actions instead.",
421
- "Read get_page_source afterwards to confirm the tap had the intended effect \u2014 a 200 only means the tap was delivered.",
421
+ "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.",
422
+ "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.",
422
423
  "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."
423
424
  ],
424
425
  "path_params": [
@@ -459,12 +460,12 @@
459
460
  "entity": "app_session",
460
461
  "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.",
461
462
  "guidance": [
462
- "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 from get_page_source whenever the resulting value matters.",
463
+ "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.",
463
464
  "The element must be an editable field (EditText on Android, TextField on iOS); typing into anything else fails with \"invalid element state\".",
464
- "The on-screen keyboard may stay open afterwards and cover lower elements \u2014 re-read the page source before the next tap.",
465
- "There is no way to empty a field on this surface: the standard clear endpoint answers 200 without reliably emptying it, so typing the value you want is the way to set it \u2014 and on iOS, where this appends, there is no way to correct a field you have already typed into.",
466
- "The keyboard is usually left open afterwards. On Android, hide_keyboard closes it. On iOS it cannot be closed without submitting, so read hide_keyboard's guidance before planning a tap below the field.",
467
- "Sending a newline closes the keyboard on iOS by SUBMITTING the field: the field resets to its placeholder and the value moves wherever the app sends it. Use it to dismiss the keyboard only when submitting is what you wanted anyway; see hide_keyboard for what the alternatives do."
465
+ "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.",
466
+ "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.",
467
+ "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.",
468
+ "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."
468
469
  ],
469
470
  "path_params": [
470
471
  {
@@ -504,6 +505,51 @@
504
505
  }
505
506
  }
506
507
  },
508
+ {
509
+ "name": "clear_element",
510
+ "method": "POST",
511
+ "path": "/wd/hub/session/{session_id}/element/{element_id}/clear",
512
+ "mode": "write",
513
+ "entity": "app_session",
514
+ "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.",
515
+ "guidance": [
516
+ "Takes no parameters of its own.",
517
+ "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.",
518
+ "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.",
519
+ "The element must be an editable field \u2014 EditText on Android, TextField on iOS. Anything else answers \"invalid element state\".",
520
+ "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\"."
521
+ ],
522
+ "path_params": [
523
+ {
524
+ "name": "session_id",
525
+ "type": "string",
526
+ "required": true,
527
+ "example": "0123456789abcdef0123456789abcdef01234567"
528
+ },
529
+ {
530
+ "name": "element_id",
531
+ "type": "string",
532
+ "required": true,
533
+ "description": "Element id from find_element.",
534
+ "example": "element-6066-11e4-a52e-4f735466cecf"
535
+ }
536
+ ],
537
+ "returns": [
538
+ "value"
539
+ ],
540
+ "responses": {
541
+ "200": {
542
+ "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.",
543
+ "schema": {
544
+ "$schema": "NullValueResponse"
545
+ }
546
+ },
547
+ "404": {
548
+ "$response": "WebDriverError"
549
+ }
550
+ },
551
+ "send_empty_body": true
552
+ },
507
553
  {
508
554
  "name": "perform_actions",
509
555
  "method": "POST",
@@ -512,11 +558,11 @@
512
558
  "entity": "app_session",
513
559
  "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.",
514
560
  "guidance": [
515
- "Coordinates are device pixels in the same space as the page-source bounds; take them from there rather than from a screenshot, whose height can include the system bars.",
561
+ "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.",
516
562
  "Tap: one touch pointer with pointerMove(duration 0, x, y), pointerDown(button 0), pause(100), pointerUp(button 0).",
517
- "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 bounds.",
563
+ "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.",
518
564
  "Long-press: replace the pause duration with 1000 or more.",
519
- "For a scroll without computing coordinates, execute_script with \"mobile: scrollGesture\" is simpler and reports whether more content remains.",
565
+ "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.",
520
566
  "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."
521
567
  ],
522
568
  "path_params": [
@@ -607,6 +653,203 @@
607
653
  }
608
654
  }
609
655
  },
656
+ {
657
+ "name": "scroll_gesture",
658
+ "method": "POST",
659
+ "path": "/wd/hub/session/{session_id}/execute/sync",
660
+ "mode": "write",
661
+ "entity": "app_session",
662
+ "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.",
663
+ "guidance": [
664
+ "ANDROID ONLY. On iOS this is a 405 \"unknown method\"; scroll_view is the iOS counterpart.",
665
+ "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.",
666
+ "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.",
667
+ "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.",
668
+ "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."
669
+ ],
670
+ "path_params": [
671
+ {
672
+ "name": "session_id",
673
+ "type": "string",
674
+ "required": true,
675
+ "example": "0123456789abcdef0123456789abcdef01234567"
676
+ }
677
+ ],
678
+ "body": [
679
+ {
680
+ "name": "script",
681
+ "type": "string",
682
+ "required": true,
683
+ "example": "mobile: scrollGesture",
684
+ "values": [
685
+ "mobile: scrollGesture"
686
+ ]
687
+ },
688
+ {
689
+ "name": "args",
690
+ "type": "array",
691
+ "required": true,
692
+ "description": "Exactly one object: the rectangle to scroll inside, the direction (up, down, left or right), and how far.",
693
+ "example": [
694
+ {
695
+ "left": 100,
696
+ "top": 500,
697
+ "width": 880,
698
+ "height": 1500,
699
+ "direction": "down",
700
+ "percent": 0.8
701
+ }
702
+ ]
703
+ }
704
+ ],
705
+ "returns": [
706
+ "value"
707
+ ],
708
+ "responses": {
709
+ "200": {
710
+ "description": "Command result; its shape depends on the command.",
711
+ "schema": {
712
+ "$schema": "AnyValueResponse"
713
+ }
714
+ },
715
+ "404": {
716
+ "$response": "WebDriverError"
717
+ },
718
+ "500": {
719
+ "$response": "WebDriverError"
720
+ }
721
+ }
722
+ },
723
+ {
724
+ "name": "scroll_view",
725
+ "method": "POST",
726
+ "path": "/wd/hub/session/{session_id}/execute/sync",
727
+ "mode": "write",
728
+ "entity": "app_session",
729
+ "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.",
730
+ "guidance": [
731
+ "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.",
732
+ "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.",
733
+ "A HORIZONTAL SWIPE IS THE SAME CALL with direction left or right, optionally with elementId naming the carousel; no coordinates are needed.",
734
+ "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.",
735
+ "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.",
736
+ "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."
737
+ ],
738
+ "path_params": [
739
+ {
740
+ "name": "session_id",
741
+ "type": "string",
742
+ "required": true,
743
+ "example": "0123456789abcdef0123456789abcdef01234567"
744
+ }
745
+ ],
746
+ "body": [
747
+ {
748
+ "name": "script",
749
+ "type": "string",
750
+ "required": true,
751
+ "values": [
752
+ "mobile: scroll"
753
+ ],
754
+ "example": "mobile: scroll"
755
+ },
756
+ {
757
+ "name": "args",
758
+ "type": "array",
759
+ "required": true,
760
+ "description": "Exactly one object: a direction, or a predicateString with toVisible, optionally with elementId naming the view to scroll inside.",
761
+ "example": [
762
+ {
763
+ "direction": "down"
764
+ }
765
+ ]
766
+ }
767
+ ],
768
+ "returns": [
769
+ "value"
770
+ ],
771
+ "responses": {
772
+ "200": {
773
+ "description": "Command result; its shape depends on the command.",
774
+ "schema": {
775
+ "$schema": "AnyValueResponse"
776
+ }
777
+ },
778
+ "404": {
779
+ "$response": "WebDriverError"
780
+ },
781
+ "500": {
782
+ "$response": "WebDriverError"
783
+ }
784
+ }
785
+ },
786
+ {
787
+ "name": "dismiss_keyboard_key",
788
+ "method": "POST",
789
+ "path": "/wd/hub/session/{session_id}/execute/sync",
790
+ "mode": "write",
791
+ "entity": "app_session",
792
+ "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.",
793
+ "guidance": [
794
+ "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.",
795
+ "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.",
796
+ "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\"].",
797
+ "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.",
798
+ "Confirm with is_keyboard_shown; on Android and on an iPad hide_keyboard closes it on its own."
799
+ ],
800
+ "path_params": [
801
+ {
802
+ "name": "session_id",
803
+ "type": "string",
804
+ "required": true,
805
+ "example": "0123456789abcdef0123456789abcdef01234567"
806
+ }
807
+ ],
808
+ "body": [
809
+ {
810
+ "name": "script",
811
+ "type": "string",
812
+ "required": true,
813
+ "example": "mobile: hideKeyboard",
814
+ "values": [
815
+ "mobile: hideKeyboard"
816
+ ]
817
+ },
818
+ {
819
+ "name": "args",
820
+ "type": "array",
821
+ "required": true,
822
+ "description": "Exactly one object whose keys field lists the key names to try, in order.",
823
+ "example": [
824
+ {
825
+ "keys": [
826
+ "Done",
827
+ "done",
828
+ "Hide keyboard",
829
+ "Dismiss"
830
+ ]
831
+ }
832
+ ]
833
+ }
834
+ ],
835
+ "returns": [
836
+ "value"
837
+ ],
838
+ "responses": {
839
+ "200": {
840
+ "description": "Command result; its shape depends on the command.",
841
+ "schema": {
842
+ "$schema": "AnyValueResponse"
843
+ }
844
+ },
845
+ "404": {
846
+ "$response": "WebDriverError"
847
+ },
848
+ "500": {
849
+ "$response": "WebDriverError"
850
+ }
851
+ }
852
+ },
610
853
  {
611
854
  "name": "execute_script",
612
855
  "method": "POST",
@@ -670,7 +913,8 @@
670
913
  "500": {
671
914
  "$response": "WebDriverError"
672
915
  }
673
- }
916
+ },
917
+ "disabled": true
674
918
  },
675
919
  {
676
920
  "name": "get_window_rect",
@@ -680,7 +924,7 @@
680
924
  "entity": "app_session",
681
925
  "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.",
682
926
  "guidance": [
683
- "On Android the height excludes the system bars, so it can be smaller than a screenshot's height; page-source bounds use this same space."
927
+ "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."
684
928
  ],
685
929
  "path_params": [
686
930
  {
@@ -718,7 +962,7 @@
718
962
  "guidance": [
719
963
  "There is no way to resume an ended session; any further step needs create_session again, which reinstalls the app.",
720
964
  "The session's video, logs and pass/fail status stay on the App Automate dashboard after it ends.",
721
- "Call setSessionStatus through execute_script first \u2014 once the session has ended that route is gone."
965
+ "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."
722
966
  ],
723
967
  "path_params": [
724
968
  {
@@ -729,23 +973,14 @@
729
973
  }
730
974
  ],
731
975
  "returns": [
976
+ "state",
732
977
  "value"
733
978
  ],
734
979
  "responses": {
735
980
  "200": {
736
- "description": "Session ended; state is \"success\" and value is empty.",
981
+ "description": "state is \"success\" and value an empty string; the body is the hub's own, not the W3C null.",
737
982
  "schema": {
738
- "type": "object",
739
- "properties": {
740
- "state": {
741
- "type": "string",
742
- "description": "\"success\" when the session was ended."
743
- },
744
- "value": {
745
- "type": "string",
746
- "description": "Empty string."
747
- }
748
- }
983
+ "$schema": "SessionDeletedResponse"
749
984
  }
750
985
  },
751
986
  "404": {
@@ -860,13 +1095,14 @@
860
1095
  "entity": "app_build",
861
1096
  "intent": "Upload an app build to App Automate from a public download link and get back the bs:// URL a session installs \u2014 use this to make a new .apk, .aab or .ipa available to devices.",
862
1097
  "guidance": [
863
- "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. There is no way to hand the bytes over from here.",
1098
+ "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.",
864
1099
  "The url must be https and reachable by BrowserStack without credentials; a link behind a login fails on their side, not the caller's.",
865
1100
  "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.",
866
1101
  "Re-uploading under the same custom_id adds a new build rather than replacing the old one; both stay listed.",
867
1102
  "Builds expire after 30 days, so a bs:// URL held from an earlier run can stop working.",
868
1103
  "Uploading a large build can take minutes \u2014 the call does not return until BrowserStack has fetched the whole file.",
869
- "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."
1104
+ "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.",
1105
+ "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."
870
1106
  ],
871
1107
  "body": [
872
1108
  {
@@ -913,7 +1149,8 @@
913
1149
  "guidance": [
914
1150
  "An account with no uploads answers with a message object or a 404 rather than an empty array; treat both as 'nothing uploaded yet'.",
915
1151
  "This is only the calling user's own uploads \u2014 use list_team_app_builds for everyone on the team.",
916
- "app_url is what create_session takes; app_id is an internal identifier and is not accepted there."
1152
+ "app_url is what create_session takes; app_id is an internal identifier and is not accepted there.",
1153
+ "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."
917
1154
  ],
918
1155
  "query": [
919
1156
  {
@@ -1097,8 +1334,7 @@
1097
1334
  "Works after the session has ended as well as during it, so the verdict can be set once the result is known.",
1098
1335
  "Only 'passed' and 'failed' are accepted; any other value is rejected with a 422.",
1099
1336
  "reason is truncated at 255 characters.",
1100
- "An unset session shows as neither passed nor failed on the dashboard, which reads as an abandoned run.",
1101
- "The same thing can be done from inside a running session with execute_script and the browserstack_executor setSessionStatus action; this way needs no open session."
1337
+ "An unset session shows as neither passed nor failed on the dashboard, which reads as an abandoned run."
1102
1338
  ],
1103
1339
  "path_params": [
1104
1340
  {
@@ -1216,7 +1452,7 @@
1216
1452
  "entity": "app_session",
1217
1453
  "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.",
1218
1454
  "guidance": [
1219
- "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 get_page_source for deciding what to act on.",
1455
+ "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.",
1220
1456
  "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.",
1221
1457
  "A screen the app marks secure comes back black, which is the app's doing and not a failure."
1222
1458
  ],
@@ -1252,8 +1488,8 @@
1252
1488
  "intent": "Go back one screen \u2014 use this to leave a detail page, close a dialog, or undo a navigation step.",
1253
1489
  "guidance": [
1254
1490
  "On Android this is the system back button and is the reliable way back.",
1255
- "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 from the page source rather than from the status.",
1256
- "Pressing back on the first screen of an app can send the app to the background and show the home screen; read the page source afterwards to confirm where you are.",
1491
+ "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.",
1492
+ "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.",
1257
1493
  "The response is null on success and says nothing about whether the screen actually changed.",
1258
1494
  "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."
1259
1495
  ],
@@ -1323,7 +1559,7 @@
1323
1559
  "guidance": [
1324
1560
  "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.",
1325
1561
  "The value must be upper case, PORTRAIT or LANDSCAPE.",
1326
- "Element positions and the window rectangle change after a rotation, so re-read the page source before acting on coordinates.",
1562
+ "Element positions and the window rectangle change after a rotation, so re-read get_element_rect and get_window_rect before acting on coordinates.",
1327
1563
  "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."
1328
1564
  ],
1329
1565
  "path_params": [
@@ -1404,7 +1640,7 @@
1404
1640
  "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.",
1405
1641
  "guidance": [
1406
1642
  "Use a name exactly as list_contexts returned it; an invented WEBVIEW name is rejected.",
1407
- "In a web view, selectors change: find_element then takes css selector and the page source is HTML rather than the native UI tree.",
1643
+ "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.",
1408
1644
  "Switch back to NATIVE_APP before driving native controls again.",
1409
1645
  "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."
1410
1646
  ],
@@ -1448,12 +1684,13 @@
1448
1684
  "entity": "app_session",
1449
1685
  "intent": "Close the on-screen keyboard \u2014 use this when it covers a button or field you need to tap after typing.",
1450
1686
  "guidance": [
1451
- "The response is true when a keyboard was dismissed and false when none was showing; false is not an error.",
1687
+ "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.",
1452
1688
  "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.",
1453
- "On iOS this FAILS rather than quietly doing nothing: 400 \"invalid element state\", message \"Did not know how to dismiss the keyboard\". The keyboard stays up and the field keeps what was typed, so treat the 400 as the normal answer on iOS and not as a broken call.",
1454
- "What does close it on iOS closes it BY SUBMITTING: send_keys with a newline, or execute_script with 'mobile: hideKeyboard' and args [{\"keys\":[\"return\"]}]. Both press Return, so the field is submitted and cleared to its placeholder and the value moves wherever the app sends it \u2014 that is a change to the app, not just to the keyboard.",
1455
- "The keys argument names a key on the APP'S OWN keyboard, so it fails the same way when that key is absent: [\"Done\"] returns the same 400 on a keyboard that offers only Return. Omitting keys fails too, and tapping an empty area above the keyboard with perform_actions returns 200 and changes nothing.",
1456
- "So on iOS there may be no way to put the keyboard away without submitting. Prefer a flow that does not need to tap underneath it \u2014 read values from get_page_source rather than tapping a control the keyboard covers. On Android this capability works normally.",
1689
+ "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.",
1690
+ "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.",
1691
+ "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.",
1692
+ "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.",
1693
+ "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.",
1457
1694
  "is_keyboard_shown reports the state this changes, and is the reliable way to tell whether the keyboard actually went away."
1458
1695
  ],
1459
1696
  "path_params": [
@@ -1464,6 +1701,50 @@
1464
1701
  "example": "0123456789abcdef0123456789abcdef01234567"
1465
1702
  }
1466
1703
  ],
1704
+ "fallbacks": [
1705
+ {
1706
+ "when": {
1707
+ "status": 400
1708
+ },
1709
+ "capability": "dismiss_keyboard_key",
1710
+ "body": {
1711
+ "script": "mobile: hideKeyboard",
1712
+ "args": [
1713
+ {
1714
+ "keys": [
1715
+ "Done",
1716
+ "done",
1717
+ "Hide keyboard",
1718
+ "Dismiss"
1719
+ ]
1720
+ }
1721
+ ]
1722
+ }
1723
+ },
1724
+ {
1725
+ "when": {
1726
+ "status": 400
1727
+ },
1728
+ "capability": "dismiss_keyboard_key",
1729
+ "body": {
1730
+ "script": "mobile: hideKeyboard",
1731
+ "args": [
1732
+ {
1733
+ "keys": [
1734
+ "Return",
1735
+ "return",
1736
+ "Go",
1737
+ "go",
1738
+ "Next",
1739
+ "next",
1740
+ "Search",
1741
+ "search"
1742
+ ]
1743
+ }
1744
+ ]
1745
+ }
1746
+ }
1747
+ ],
1467
1748
  "returns": [
1468
1749
  "value"
1469
1750
  ],
@@ -1692,11 +1973,11 @@
1692
1973
  "entity": "app_session",
1693
1974
  "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.",
1694
1975
  "guidance": [
1695
- "Android only. iOS has no equivalent: use press_back to go back, and execute_script with 'mobile: pressButton' for home and the volume keys.",
1976
+ "Android only. iOS has no equivalent: use press_back to go back, and press_button for home and the volume keys.",
1696
1977
  "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.",
1697
1978
  "press_back is the clearer way to send 4.",
1698
1979
  "The response is null on success and says nothing about what the key did.",
1699
- "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use press_back or 'mobile: pressButton' there."
1980
+ "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use press_back or press_button there."
1700
1981
  ],
1701
1982
  "path_params": [
1702
1983
  {
@@ -1754,7 +2035,7 @@
1754
2035
  "Returns the Android package name; it answers with the launcher's package when the app has been sent to the background.",
1755
2036
  "This is the quickest way to learn the app under test's own package id, which launch_app and terminate_app both need.",
1756
2037
  "Android only; get_active_app_info is the iOS equivalent and returns an object rather than a string.",
1757
- "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use 'mobile: activeAppInfo' there."
2038
+ "Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use get_active_app_info there."
1758
2039
  ],
1759
2040
  "path_params": [
1760
2041
  {
@@ -1809,7 +2090,7 @@
1809
2090
  "entity": "app_session",
1810
2091
  "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.",
1811
2092
  "guidance": [
1812
- "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.",
2093
+ "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.",
1813
2094
  "Launching an app that is already in the foreground does nothing and still reports success.",
1814
2095
  "To restart an app from a clean state, call terminate_app first and then this.",
1815
2096
  "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.",
@@ -1882,7 +2163,7 @@
1882
2163
  "entity": "app_session",
1883
2164
  "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.",
1884
2165
  "guidance": [
1885
- "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.",
2166
+ "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.",
1886
2167
  "Returns true when the app was running and false when it was not; false is not an error.",
1887
2168
  "The app stays installed and keeps its data; only the running process is stopped.",
1888
2169
  "This does not clear state between runs \u2014 relaunching shows the app where it was unless the app itself resets."
@@ -1954,7 +2235,7 @@
1954
2235
  "entity": "app_session",
1955
2236
  "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.",
1956
2237
  "guidance": [
1957
- "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.",
2238
+ "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.",
1958
2239
  "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.",
1959
2240
  "4 is the only state in which the app is on screen and can be driven.",
1960
2241
  "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."
@@ -2174,7 +2455,7 @@
2174
2455
  "The session must have been started with enable_biometric true; it cannot be turned on once the session is running.",
2175
2456
  "Call it only while the prompt is actually on screen \u2014 with no prompt showing it fails with BROWSERSTACK_COMMAND_EXECUTION_FAILED.",
2176
2457
  "biometricMatch takes \"pass\" or \"fail\"; \"fail\" is how to exercise the app's rejection path.",
2177
- "Read the page source afterwards to see what the app did with the answer.",
2458
+ "Afterwards, find the element the app shows on success or on failure to see what it did with the answer.",
2178
2459
  "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."
2179
2460
  ],
2180
2461
  "path_params": [
@@ -2367,7 +2648,7 @@
2367
2648
  "guidance": [
2368
2649
  "iOS only; press_keycode is the Android equivalent and the two are not interchangeable.",
2369
2650
  "The button names carry no separator: home, volumeup, volumedown.",
2370
- "Pressing home sends the app to the background \u2014 bring it back with launch_app, passing bundleId rather than appId, and confirm from the page source that the app is in front again.",
2651
+ "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.",
2371
2652
  "The response is null on success and says nothing about what the button did."
2372
2653
  ],
2373
2654
  "path_params": [
@@ -2549,7 +2830,8 @@
2549
2830
  "Only `getprop <property>` 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.",
2550
2831
  "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.",
2551
2832
  "The device name and OS version are already in the session record, so get_session answers those without this.",
2552
- "The whole line must be sent exactly as shown, with no extra spaces: it is matched against a fixed form."
2833
+ "The whole line must be sent exactly as shown, with no extra spaces: it is matched against a fixed form.",
2834
+ "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."
2553
2835
  ],
2554
2836
  "path_params": [
2555
2837
  {
@@ -2601,14 +2883,25 @@
2601
2883
  "encoding": "multipart",
2602
2884
  "local_only": true,
2603
2885
  "intent": "Upload an app build that is on this machine to App Automate 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.",
2604
- "guidance": [
2605
- "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, then move the build there.",
2606
- "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.",
2607
- "Only .apk, .aab and .ipa are accepted, and no part of the path may be a hidden folder, so a build under a dot-directory has to be moved out of it.",
2608
- "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.",
2609
- "upload_app_build takes a public https link instead and is the one to use when the build is not on this machine.",
2610
- "A large build takes minutes, because the call does not return until the whole file has been sent."
2611
- ],
2886
+ "guidance": {
2887
+ "shared": [
2888
+ "Only .apk, .aab and .ipa are accepted.",
2889
+ "A large build takes minutes, because the upload does not return until the whole file has been sent.",
2890
+ "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."
2891
+ ],
2892
+ "local": [
2893
+ "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.",
2894
+ "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.",
2895
+ "No part of the path may be a hidden folder, so a build under a dot-directory has to be moved out of it.",
2896
+ "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."
2897
+ ],
2898
+ "remote": [
2899
+ "Cheapest route first: list_app_builds or list_team_app_builds, if the build was uploaded before.",
2900
+ "Then upload_app_build, if the build sits at a public https link \u2014 the only route that needs nothing from the user.",
2901
+ "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.",
2902
+ "Take the bs:// url back from them and pass it as the app for create_session."
2903
+ ]
2904
+ },
2612
2905
  "body": [
2613
2906
  {
2614
2907
  "name": "file",
@@ -2640,8 +2933,7 @@
2640
2933
  "401": {
2641
2934
  "$response": "Unauthorized"
2642
2935
  }
2643
- },
2644
- "unavailable_note": "This server does not run on your own machine, so it cannot read a file you name \u2014 a path sent here would point at the machine hosting the server. Upload the build yourself with a multipart request carrying the file to the same endpoint, which the asset-upload helper will write out for you, and then use the bs:// url it returns as the app for create_session. upload_app_build does the same thing from a public download link, if the build is reachable at one."
2936
+ }
2645
2937
  },
2646
2938
  {
2647
2939
  "name": "find_elements",
@@ -2703,6 +2995,144 @@
2703
2995
  "$response": "WebDriverError"
2704
2996
  }
2705
2997
  }
2998
+ },
2999
+ {
3000
+ "name": "get_element_text",
3001
+ "method": "GET",
3002
+ "path": "/wd/hub/session/{session_id}/element/{element_id}/text",
3003
+ "mode": "read",
3004
+ "entity": "app_session",
3005
+ "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.",
3006
+ "guidance": [
3007
+ "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.",
3008
+ "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).",
3009
+ "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.",
3010
+ "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."
3011
+ ],
3012
+ "path_params": [
3013
+ {
3014
+ "name": "session_id",
3015
+ "type": "string",
3016
+ "required": true,
3017
+ "example": "0123456789abcdef0123456789abcdef01234567"
3018
+ },
3019
+ {
3020
+ "name": "element_id",
3021
+ "type": "string",
3022
+ "required": true,
3023
+ "description": "Element id from find_element or find_elements.",
3024
+ "example": "00000000-0000-0001-ffff-ffff00000012"
3025
+ }
3026
+ ],
3027
+ "returns": [
3028
+ "value"
3029
+ ],
3030
+ "responses": {
3031
+ "200": {
3032
+ "description": "The element's visible text.",
3033
+ "schema": {
3034
+ "$schema": "StringValueResponse"
3035
+ }
3036
+ },
3037
+ "404": {
3038
+ "$response": "WebDriverError"
3039
+ }
3040
+ }
3041
+ },
3042
+ {
3043
+ "name": "get_element_rect",
3044
+ "method": "GET",
3045
+ "path": "/wd/hub/session/{session_id}/element/{element_id}/rect",
3046
+ "mode": "read",
3047
+ "entity": "app_session",
3048
+ "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.",
3049
+ "guidance": [
3050
+ "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.",
3051
+ "One call per element: the coordinates to aim at, without reading the whole screen.",
3052
+ "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."
3053
+ ],
3054
+ "path_params": [
3055
+ {
3056
+ "name": "session_id",
3057
+ "type": "string",
3058
+ "required": true,
3059
+ "example": "0123456789abcdef0123456789abcdef01234567"
3060
+ },
3061
+ {
3062
+ "name": "element_id",
3063
+ "type": "string",
3064
+ "required": true,
3065
+ "description": "Element id from find_element or find_elements.",
3066
+ "example": "00000000-0000-0001-ffff-ffff00000012"
3067
+ }
3068
+ ],
3069
+ "returns": [
3070
+ "value.x",
3071
+ "value.y",
3072
+ "value.width",
3073
+ "value.height"
3074
+ ],
3075
+ "responses": {
3076
+ "200": {
3077
+ "description": "The element's rectangle on screen.",
3078
+ "schema": {
3079
+ "$schema": "RectResponse"
3080
+ }
3081
+ },
3082
+ "404": {
3083
+ "$response": "WebDriverError"
3084
+ }
3085
+ }
3086
+ },
3087
+ {
3088
+ "name": "get_element_attribute",
3089
+ "method": "GET",
3090
+ "path": "/wd/hub/session/{session_id}/element/{element_id}/attribute/{name}",
3091
+ "mode": "read",
3092
+ "entity": "app_session",
3093
+ "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.",
3094
+ "guidance": [
3095
+ "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.",
3096
+ "Booleans come back as the strings \"true\" and \"false\", not JSON booleans, so compare as text.",
3097
+ "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.",
3098
+ "For the element's text use get_element_text and for its position get_element_rect; this is for everything else."
3099
+ ],
3100
+ "path_params": [
3101
+ {
3102
+ "name": "session_id",
3103
+ "type": "string",
3104
+ "required": true,
3105
+ "example": "0123456789abcdef0123456789abcdef01234567"
3106
+ },
3107
+ {
3108
+ "name": "element_id",
3109
+ "type": "string",
3110
+ "required": true,
3111
+ "description": "Element id from find_element or find_elements.",
3112
+ "example": "00000000-0000-0001-ffff-ffff00000012"
3113
+ },
3114
+ {
3115
+ "name": "name",
3116
+ "type": "string",
3117
+ "required": true,
3118
+ "description": "The attribute to read, as named in the UI tree.",
3119
+ "example": "content-desc"
3120
+ }
3121
+ ],
3122
+ "returns": [
3123
+ "value"
3124
+ ],
3125
+ "responses": {
3126
+ "200": {
3127
+ "description": "The attribute's value as a string; an absent one is the string \"null\" on Android.",
3128
+ "schema": {
3129
+ "$schema": "NullableStringResponse"
3130
+ }
3131
+ },
3132
+ "404": {
3133
+ "$response": "WebDriverError"
3134
+ }
3135
+ }
2706
3136
  }
2707
3137
  ],
2708
3138
  "schemas": {
@@ -3147,6 +3577,33 @@
3147
3577
  }
3148
3578
  }
3149
3579
  }
3580
+ },
3581
+ "SessionDeletedResponse": {
3582
+ "type": "object",
3583
+ "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.",
3584
+ "properties": {
3585
+ "state": {
3586
+ "type": "string",
3587
+ "description": "\"success\" when the session was closed."
3588
+ },
3589
+ "status": {
3590
+ "type": "integer",
3591
+ "description": "The hub's own legacy status code; 0 on success."
3592
+ },
3593
+ "value": {
3594
+ "type": "string",
3595
+ "description": "Always empty on success."
3596
+ }
3597
+ }
3598
+ },
3599
+ "NullableStringResponse": {
3600
+ "type": "object",
3601
+ "properties": {
3602
+ "value": {
3603
+ "type": "string",
3604
+ "description": "Null when the attribute is absent."
3605
+ }
3606
+ }
3150
3607
  }
3151
3608
  },
3152
3609
  "responses": {
@@ -3222,7 +3679,14 @@
3222
3679
  "get_simulated_location",
3223
3680
  "get_active_app_info",
3224
3681
  "read_device_property",
3225
- "find_elements"
3682
+ "find_elements",
3683
+ "get_element_text",
3684
+ "get_element_rect",
3685
+ "get_element_attribute",
3686
+ "clear_element",
3687
+ "scroll_gesture",
3688
+ "dismiss_keyboard_key",
3689
+ "scroll_view"
3226
3690
  ]
3227
3691
  },
3228
3692
  "device": {