@browserstack/mcp-server 2.3.0-beta.2 → 2.3.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capability/{automate.capability-index.json → devicecontrol.capability-index.json} +46 -31
- package/capability/{appautomate.capability-index.json → mobiledevicecontrol.capability-index.json} +22 -19
- package/dist/lib/error.d.ts +10 -0
- package/dist/lib/error.js +64 -12
- package/dist/lib/utils.js +13 -2
- package/dist/tools/automate-utils/fetch-screenshots.d.ts +6 -2
- package/dist/tools/automate-utils/fetch-screenshots.js +88 -19
- package/dist/tools/automate.js +2 -2
- package/dist/tools/capability-registry/narrow.d.ts +68 -0
- package/dist/tools/capability-registry/narrow.js +140 -0
- package/dist/tools/capability-registry/register.js +68 -3
- package/dist/tools/capability-registry/search.d.ts +0 -18
- package/dist/tools/capability-registry/search.js +49 -1
- package/dist/tools/capability-registry/types.d.ts +26 -0
- package/dist/tools/testmanagement-utils/update-testcase.js +5 -3
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"version": "0
|
|
4
|
-
"build_id": "
|
|
5
|
-
"
|
|
6
|
-
"summary": "
|
|
3
|
+
"version": "1.0",
|
|
4
|
+
"build_id": "8445466_2026-10-19T06:53:46Z",
|
|
5
|
+
"devicecontrol": {
|
|
6
|
+
"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, read the page HTML, find elements by CSS or XPath, click, type, clear, run JavaScript, 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 through execute_script, 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.",
|
|
7
7
|
"routing_terms": [
|
|
8
8
|
"automate",
|
|
9
9
|
"browser",
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"network logs",
|
|
32
32
|
"har",
|
|
33
33
|
"browser session",
|
|
34
|
-
"cross browser"
|
|
34
|
+
"cross browser",
|
|
35
|
+
"device control",
|
|
36
|
+
"browser control"
|
|
35
37
|
],
|
|
36
38
|
"base_url": "https://hub.browserstack.com",
|
|
37
39
|
"hosts": {
|
|
@@ -50,10 +52,12 @@
|
|
|
50
52
|
"entity": "browser",
|
|
51
53
|
"intent": "List every operating system, browser and version combination this account can run a web session on, with the exact names create_session expects.",
|
|
52
54
|
"guidance": [
|
|
53
|
-
"
|
|
55
|
+
"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.",
|
|
56
|
+
"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.",
|
|
54
57
|
"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.",
|
|
55
|
-
"
|
|
56
|
-
"
|
|
58
|
+
"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.",
|
|
59
|
+
"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.",
|
|
60
|
+
"Pass the strings into create_session exactly as they appear here; an unlisted combination is refused rather than substituted."
|
|
57
61
|
],
|
|
58
62
|
"returns": [
|
|
59
63
|
"browser",
|
|
@@ -74,7 +78,18 @@
|
|
|
74
78
|
"$response": "Unauthorized"
|
|
75
79
|
}
|
|
76
80
|
},
|
|
77
|
-
"host": "api"
|
|
81
|
+
"host": "api",
|
|
82
|
+
"narrowable": {
|
|
83
|
+
"fields": [
|
|
84
|
+
"os",
|
|
85
|
+
"os_version",
|
|
86
|
+
"browser",
|
|
87
|
+
"browser_version",
|
|
88
|
+
"device",
|
|
89
|
+
"real_mobile"
|
|
90
|
+
],
|
|
91
|
+
"note": "The endpoint takes no parameters of its own, so this narrowing is applied to the response after the call."
|
|
92
|
+
}
|
|
78
93
|
},
|
|
79
94
|
{
|
|
80
95
|
"name": "get_plan",
|
|
@@ -204,7 +219,7 @@
|
|
|
204
219
|
"type": "string",
|
|
205
220
|
"maxLength": 255,
|
|
206
221
|
"description": "Short explanation shown beside the status.",
|
|
207
|
-
"example": "
|
|
222
|
+
"example": "The page behaved as expected"
|
|
208
223
|
}
|
|
209
224
|
],
|
|
210
225
|
"returns": [
|
|
@@ -364,14 +379,14 @@
|
|
|
364
379
|
"type": "string",
|
|
365
380
|
"json_path": "/capabilities/alwaysMatch/bstack:options/projectName",
|
|
366
381
|
"description": "Groups sessions on the dashboard.",
|
|
367
|
-
"example": "
|
|
382
|
+
"example": "Web checkout"
|
|
368
383
|
},
|
|
369
384
|
{
|
|
370
385
|
"name": "build_name",
|
|
371
386
|
"type": "string",
|
|
372
387
|
"json_path": "/capabilities/alwaysMatch/bstack:options/buildName",
|
|
373
388
|
"description": "Build the session is filed under.",
|
|
374
|
-
"example": "
|
|
389
|
+
"example": "nightly"
|
|
375
390
|
},
|
|
376
391
|
{
|
|
377
392
|
"name": "session_name",
|
|
@@ -379,7 +394,7 @@
|
|
|
379
394
|
"maxLength": 255,
|
|
380
395
|
"json_path": "/capabilities/alwaysMatch/bstack:options/sessionName",
|
|
381
396
|
"description": "Name shown for this session.",
|
|
382
|
-
"example": "
|
|
397
|
+
"example": "sign in and add to basket"
|
|
383
398
|
}
|
|
384
399
|
],
|
|
385
400
|
"returns": [
|
|
@@ -425,7 +440,7 @@
|
|
|
425
440
|
"required": true,
|
|
426
441
|
"format": "uri",
|
|
427
442
|
"description": "Absolute URL including the scheme.",
|
|
428
|
-
"example": "https://www.
|
|
443
|
+
"example": "https://www.example.com/products"
|
|
429
444
|
}
|
|
430
445
|
],
|
|
431
446
|
"returns": [
|
|
@@ -624,7 +639,7 @@
|
|
|
624
639
|
"entity": "browser_session",
|
|
625
640
|
"intent": "Get the current page's HTML \u2014 use this to find selectors, or to read content a screenshot cannot show.",
|
|
626
641
|
"guidance": [
|
|
627
|
-
"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
|
|
642
|
+
"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.",
|
|
628
643
|
"This is the page as the browser has it now, including anything scripts have changed, not the HTML the server sent.",
|
|
629
644
|
"Inside a frame this returns that frame's document; switch_to_frame with a null id first to get the top-level page back."
|
|
630
645
|
],
|
|
@@ -691,7 +706,7 @@
|
|
|
691
706
|
"type": "string",
|
|
692
707
|
"required": true,
|
|
693
708
|
"description": "The selector itself, interpreted according to using.",
|
|
694
|
-
"example": "
|
|
709
|
+
"example": "#add-to-cart"
|
|
695
710
|
}
|
|
696
711
|
],
|
|
697
712
|
"returns": [
|
|
@@ -748,7 +763,7 @@
|
|
|
748
763
|
"type": "string",
|
|
749
764
|
"required": true,
|
|
750
765
|
"description": "The selector itself, interpreted according to using.",
|
|
751
|
-
"example": ".
|
|
766
|
+
"example": ".product-card"
|
|
752
767
|
}
|
|
753
768
|
],
|
|
754
769
|
"returns": [
|
|
@@ -791,7 +806,7 @@
|
|
|
791
806
|
"type": "string",
|
|
792
807
|
"required": true,
|
|
793
808
|
"description": "Element id from find_element.",
|
|
794
|
-
"example": "f.
|
|
809
|
+
"example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
|
|
795
810
|
}
|
|
796
811
|
],
|
|
797
812
|
"returns": [
|
|
@@ -819,7 +834,7 @@
|
|
|
819
834
|
"intent": "Type into a field \u2014 use this to fill a search box, a login form or any editable input.",
|
|
820
835
|
"guidance": [
|
|
821
836
|
"APPENDS to whatever the field already holds. Call clear_element first to replace the value rather than adding to it.",
|
|
822
|
-
"Read the result back with get_element_property on \"value\"
|
|
837
|
+
"Read the result back with get_element_property on \"value\": a field's current contents are a property, not text.",
|
|
823
838
|
"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, or call arguments[0].form.submit() through execute_script with the field as the argument.",
|
|
824
839
|
"The element must be editable; typing into anything else answers \"element not interactable\".",
|
|
825
840
|
"A site's own autocomplete or dropdown may swallow the keystrokes and require a click on a suggestion instead."
|
|
@@ -836,7 +851,7 @@
|
|
|
836
851
|
"type": "string",
|
|
837
852
|
"required": true,
|
|
838
853
|
"description": "Element id from find_element.",
|
|
839
|
-
"example": "f.
|
|
854
|
+
"example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
|
|
840
855
|
}
|
|
841
856
|
],
|
|
842
857
|
"body": [
|
|
@@ -845,7 +860,7 @@
|
|
|
845
860
|
"type": "string",
|
|
846
861
|
"required": true,
|
|
847
862
|
"description": "The characters to type. End with \\n to submit.",
|
|
848
|
-
"example": "
|
|
863
|
+
"example": "hello"
|
|
849
864
|
}
|
|
850
865
|
],
|
|
851
866
|
"returns": [
|
|
@@ -888,7 +903,7 @@
|
|
|
888
903
|
"type": "string",
|
|
889
904
|
"required": true,
|
|
890
905
|
"description": "Element id from find_element.",
|
|
891
|
-
"example": "f.
|
|
906
|
+
"example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
|
|
892
907
|
}
|
|
893
908
|
],
|
|
894
909
|
"returns": [
|
|
@@ -931,7 +946,7 @@
|
|
|
931
946
|
"type": "string",
|
|
932
947
|
"required": true,
|
|
933
948
|
"description": "Element id from find_element.",
|
|
934
|
-
"example": "f.
|
|
949
|
+
"example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
|
|
935
950
|
}
|
|
936
951
|
],
|
|
937
952
|
"returns": [
|
|
@@ -958,7 +973,7 @@
|
|
|
958
973
|
"entity": "browser_session",
|
|
959
974
|
"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.",
|
|
960
975
|
"guidance": [
|
|
961
|
-
"THIS IS THE ONE THAT READS A FIELD'S CURRENT VALUE.
|
|
976
|
+
"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.",
|
|
962
977
|
"Useful names: value, checked, selected, disabled, innerText, href.",
|
|
963
978
|
"A property the element does not have comes back null, not an error."
|
|
964
979
|
],
|
|
@@ -974,7 +989,7 @@
|
|
|
974
989
|
"type": "string",
|
|
975
990
|
"required": true,
|
|
976
991
|
"description": "Element id from find_element.",
|
|
977
|
-
"example": "f.
|
|
992
|
+
"example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
|
|
978
993
|
},
|
|
979
994
|
{
|
|
980
995
|
"name": "name",
|
|
@@ -1023,14 +1038,14 @@
|
|
|
1023
1038
|
"type": "string",
|
|
1024
1039
|
"required": true,
|
|
1025
1040
|
"description": "Element id from find_element.",
|
|
1026
|
-
"example": "f.
|
|
1041
|
+
"example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
|
|
1027
1042
|
},
|
|
1028
1043
|
{
|
|
1029
1044
|
"name": "name",
|
|
1030
1045
|
"type": "string",
|
|
1031
1046
|
"required": true,
|
|
1032
1047
|
"description": "The attribute to read.",
|
|
1033
|
-
"example": "
|
|
1048
|
+
"example": "href"
|
|
1034
1049
|
}
|
|
1035
1050
|
],
|
|
1036
1051
|
"returns": [
|
|
@@ -1072,7 +1087,7 @@
|
|
|
1072
1087
|
"type": "string",
|
|
1073
1088
|
"required": true,
|
|
1074
1089
|
"description": "Element id from find_element.",
|
|
1075
|
-
"example": "f.
|
|
1090
|
+
"example": "f.00000000000000000000000000000000.d.0000000000000000.e.1"
|
|
1076
1091
|
}
|
|
1077
1092
|
],
|
|
1078
1093
|
"returns": [
|
|
@@ -1154,7 +1169,7 @@
|
|
|
1154
1169
|
"guidance": [
|
|
1155
1170
|
"Returned inline as base64 and typically tens of kilobytes, so take one when the picture is needed rather than after every step.",
|
|
1156
1171
|
"The VIEWPORT only, not the whole page: anything below the fold needs scrolling into view first with execute_script.",
|
|
1157
|
-
"
|
|
1172
|
+
"get_title and get_element_property answer most checks far more cheaply than reading an image."
|
|
1158
1173
|
],
|
|
1159
1174
|
"path_params": [
|
|
1160
1175
|
{
|
|
@@ -1379,7 +1394,7 @@
|
|
|
1379
1394
|
"type": "string",
|
|
1380
1395
|
"required": true,
|
|
1381
1396
|
"description": "Handle from list_window_handles.",
|
|
1382
|
-
"example": "
|
|
1397
|
+
"example": "00000000000000000000000000000000"
|
|
1383
1398
|
}
|
|
1384
1399
|
],
|
|
1385
1400
|
"returns": [
|
|
@@ -2064,4 +2079,4 @@
|
|
|
2064
2079
|
}
|
|
2065
2080
|
}
|
|
2066
2081
|
}
|
|
2067
|
-
}
|
|
2082
|
+
}
|
package/capability/{appautomate.capability-index.json → mobiledevicecontrol.capability-index.json}
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"version": "0
|
|
4
|
-
"build_id": "
|
|
5
|
-
"
|
|
6
|
-
"summary": "
|
|
3
|
+
"version": "1.0",
|
|
4
|
+
"build_id": "6782f7e_2026-10-16T13:54:22Z",
|
|
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.",
|
|
7
7
|
"routing_terms": [
|
|
8
8
|
"app automate",
|
|
9
9
|
"appium",
|
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
"os build",
|
|
52
52
|
"upload apk",
|
|
53
53
|
"upload ipa",
|
|
54
|
-
"local build"
|
|
54
|
+
"local build",
|
|
55
|
+
"mobile device control",
|
|
56
|
+
"device control",
|
|
57
|
+
"drive a device"
|
|
55
58
|
],
|
|
56
59
|
"base_url": "https://hub-cloud.browserstack.com",
|
|
57
60
|
"auth": {
|
|
@@ -69,10 +72,10 @@
|
|
|
69
72
|
"guidance": [
|
|
70
73
|
"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.",
|
|
71
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.",
|
|
72
|
-
"The session ends itself after idle_timeout seconds with no command (90 by default,
|
|
73
|
-
"Every session occupies a device and consumes App Automate minutes until it ends: always finish with
|
|
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
|
+
"Every session occupies a device and consumes App Automate minutes until it ends: always finish with set_session_status and then delete_session.",
|
|
74
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.",
|
|
75
|
-
"device_name and platform_version must be a combination
|
|
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.",
|
|
76
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.",
|
|
77
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.",
|
|
78
81
|
"Credentials belong in the Authorization header, never inside bstack:options: capabilities are echoed back in session-not-created errors.",
|
|
@@ -145,21 +148,21 @@
|
|
|
145
148
|
"type": "string",
|
|
146
149
|
"json_path": "/capabilities/alwaysMatch/bstack:options/projectName",
|
|
147
150
|
"description": "Groups sessions on the App Automate dashboard.",
|
|
148
|
-
"example": "
|
|
151
|
+
"example": "Mobile checkout"
|
|
149
152
|
},
|
|
150
153
|
{
|
|
151
154
|
"name": "build_name",
|
|
152
155
|
"type": "string",
|
|
153
156
|
"json_path": "/capabilities/alwaysMatch/bstack:options/buildName",
|
|
154
157
|
"description": "Build the session is filed under on the dashboard.",
|
|
155
|
-
"example": "
|
|
158
|
+
"example": "nightly"
|
|
156
159
|
},
|
|
157
160
|
{
|
|
158
161
|
"name": "session_name",
|
|
159
162
|
"type": "string",
|
|
160
163
|
"json_path": "/capabilities/alwaysMatch/bstack:options/sessionName",
|
|
161
164
|
"description": "Name shown for this session on the dashboard.",
|
|
162
|
-
"example": "
|
|
165
|
+
"example": "sign in and add to basket"
|
|
163
166
|
},
|
|
164
167
|
{
|
|
165
168
|
"name": "interactive_debugging",
|
|
@@ -387,7 +390,7 @@
|
|
|
387
390
|
"type": "string",
|
|
388
391
|
"required": true,
|
|
389
392
|
"description": "The selector itself, interpreted according to using.",
|
|
390
|
-
"example": "
|
|
393
|
+
"example": "cart-button"
|
|
391
394
|
}
|
|
392
395
|
],
|
|
393
396
|
"returns": [
|
|
@@ -483,7 +486,7 @@
|
|
|
483
486
|
"type": "string",
|
|
484
487
|
"required": true,
|
|
485
488
|
"description": "The characters to type.",
|
|
486
|
-
"example": "
|
|
489
|
+
"example": "hello"
|
|
487
490
|
}
|
|
488
491
|
],
|
|
489
492
|
"returns": [
|
|
@@ -612,8 +615,8 @@
|
|
|
612
615
|
"entity": "app_session",
|
|
613
616
|
"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.",
|
|
614
617
|
"guidance": [
|
|
615
|
-
"
|
|
616
|
-
"Keep
|
|
618
|
+
"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.",
|
|
619
|
+
"Keep any executor reason to one plain line under 256 characters with no escape sequences such as newlines.",
|
|
617
620
|
"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.",
|
|
618
621
|
"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.",
|
|
619
622
|
"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."
|
|
@@ -1121,7 +1124,7 @@
|
|
|
1121
1124
|
"type": "string",
|
|
1122
1125
|
"maxLength": 255,
|
|
1123
1126
|
"description": "Short explanation shown beside the status.",
|
|
1124
|
-
"example": "
|
|
1127
|
+
"example": "The app behaved as expected"
|
|
1125
1128
|
}
|
|
1126
1129
|
],
|
|
1127
1130
|
"returns": [
|
|
@@ -1747,7 +1750,7 @@
|
|
|
1747
1750
|
"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.",
|
|
1748
1751
|
"guidance": [
|
|
1749
1752
|
"Returns the Android package name; it answers with the launcher's package when the app has been sent to the background.",
|
|
1750
|
-
"This is the quickest way to learn the app under test's own package id, which launch_app
|
|
1753
|
+
"This is the quickest way to learn the app under test's own package id, which launch_app and terminate_app both need.",
|
|
1751
1754
|
"Android only; get_active_app_info is the iOS equivalent and returns an object rather than a string.",
|
|
1752
1755
|
"Called on iOS this is a 405 \"unknown method\" rather than a silent no-op; use 'mobile: activeAppInfo' there."
|
|
1753
1756
|
],
|
|
@@ -2681,7 +2684,7 @@
|
|
|
2681
2684
|
"type": "string",
|
|
2682
2685
|
"required": true,
|
|
2683
2686
|
"description": "The selector itself, interpreted according to using.",
|
|
2684
|
-
"example": "
|
|
2687
|
+
"example": "cart-button"
|
|
2685
2688
|
}
|
|
2686
2689
|
],
|
|
2687
2690
|
"returns": [
|
|
@@ -3308,4 +3311,4 @@
|
|
|
3308
3311
|
"api": "https://api-cloud.browserstack.com"
|
|
3309
3312
|
}
|
|
3310
3313
|
}
|
|
3311
|
-
}
|
|
3314
|
+
}
|
package/dist/lib/error.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the most specific human-readable text for an error raised by an
|
|
4
|
+
* API call, capped at 500 characters. For an AxiosError with a response body
|
|
5
|
+
* it prefers the body's `message`, `error` or `errors` field, serialising
|
|
6
|
+
* objects, and uses the whole body when none of those fields exist. An empty,
|
|
7
|
+
* whitespace-only or HTML-document body falls back to the error's own message.
|
|
8
|
+
* @param err - The error object to describe
|
|
9
|
+
* @param defaultText - The fallback error message
|
|
10
|
+
*/
|
|
11
|
+
export declare function errorText(err: unknown, defaultText: string): string;
|
|
2
12
|
/**
|
|
3
13
|
* Formats an AxiosError into a CallToolResult with an appropriate message.
|
|
4
14
|
* @param err - The error object to format
|
package/dist/lib/error.js
CHANGED
|
@@ -1,23 +1,75 @@
|
|
|
1
1
|
import { AxiosError } from "axios";
|
|
2
|
+
const MAX_ERROR_TEXT = 500;
|
|
3
|
+
const BODY_FIELDS = ["message", "error", "errors"];
|
|
4
|
+
function clip(text) {
|
|
5
|
+
return text.length > MAX_ERROR_TEXT ? text.slice(0, MAX_ERROR_TEXT) : text;
|
|
6
|
+
}
|
|
7
|
+
const HTML_DOCUMENT = /^\s*<(!doctype|html)\b/i;
|
|
8
|
+
function stringify(value) {
|
|
9
|
+
if (typeof value === "string") {
|
|
10
|
+
return HTML_DOCUMENT.test(value) ? "" : value.trim();
|
|
11
|
+
}
|
|
12
|
+
if (value === undefined || value === null)
|
|
13
|
+
return "";
|
|
14
|
+
try {
|
|
15
|
+
const json = JSON.stringify(value);
|
|
16
|
+
return json === "{}" || json === "[]" ? "" : (json ?? "");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Text taken from an API error body: the first non-empty `message`, `error`
|
|
24
|
+
* or `errors` field when any of them is present, otherwise the whole body.
|
|
25
|
+
* Returns undefined when the body carries no usable text.
|
|
26
|
+
*/
|
|
27
|
+
function bodyText(data) {
|
|
28
|
+
if (typeof data !== "object" || data === null) {
|
|
29
|
+
const text = stringify(data);
|
|
30
|
+
return text ? clip(text) : undefined;
|
|
31
|
+
}
|
|
32
|
+
const record = data;
|
|
33
|
+
const named = BODY_FIELDS.filter((key) => key in record);
|
|
34
|
+
if (named.length > 0) {
|
|
35
|
+
for (const key of named) {
|
|
36
|
+
const text = stringify(record[key]);
|
|
37
|
+
if (text)
|
|
38
|
+
return clip(text);
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const text = stringify(record);
|
|
43
|
+
return text ? clip(text) : undefined;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Returns the most specific human-readable text for an error raised by an
|
|
47
|
+
* API call, capped at 500 characters. For an AxiosError with a response body
|
|
48
|
+
* it prefers the body's `message`, `error` or `errors` field, serialising
|
|
49
|
+
* objects, and uses the whole body when none of those fields exist. An empty,
|
|
50
|
+
* whitespace-only or HTML-document body falls back to the error's own message.
|
|
51
|
+
* @param err - The error object to describe
|
|
52
|
+
* @param defaultText - The fallback error message
|
|
53
|
+
*/
|
|
54
|
+
export function errorText(err, defaultText) {
|
|
55
|
+
if (err instanceof AxiosError) {
|
|
56
|
+
const text = bodyText(err.response?.data);
|
|
57
|
+
if (text)
|
|
58
|
+
return text;
|
|
59
|
+
return clip(err.message || defaultText);
|
|
60
|
+
}
|
|
61
|
+
if (err instanceof Error)
|
|
62
|
+
return clip(err.message || defaultText);
|
|
63
|
+
return defaultText;
|
|
64
|
+
}
|
|
2
65
|
/**
|
|
3
66
|
* Formats an AxiosError into a CallToolResult with an appropriate message.
|
|
4
67
|
* @param err - The error object to format
|
|
5
68
|
* @param defaultText - The fallback error message
|
|
6
69
|
*/
|
|
7
70
|
export function formatAxiosError(err, defaultText) {
|
|
8
|
-
let text = defaultText;
|
|
9
|
-
if (err instanceof AxiosError && err.response?.data) {
|
|
10
|
-
const message = err.response.data.message ||
|
|
11
|
-
err.response.data.error ||
|
|
12
|
-
err.message ||
|
|
13
|
-
defaultText;
|
|
14
|
-
text = message;
|
|
15
|
-
}
|
|
16
|
-
else if (err instanceof Error) {
|
|
17
|
-
text = err.message;
|
|
18
|
-
}
|
|
19
71
|
return {
|
|
20
|
-
content: [{ type: "text", text }],
|
|
72
|
+
content: [{ type: "text", text: errorText(err, defaultText) }],
|
|
21
73
|
isError: true,
|
|
22
74
|
};
|
|
23
75
|
}
|
package/dist/lib/utils.js
CHANGED
|
@@ -7,6 +7,9 @@ export function sanitizeUrlParam(param) {
|
|
|
7
7
|
return param.replace(/[;&|`$(){}[\]<>]/g, "");
|
|
8
8
|
}
|
|
9
9
|
const ONE_MB = 1048576;
|
|
10
|
+
// sharp's default (~268M px) lets one image decode to ~1 GB of RGBA; 25M px caps it near 100 MB.
|
|
11
|
+
const MAX_INPUT_PIXELS = 25_000_000;
|
|
12
|
+
const MAX_IMAGE_DIMENSION = 1920;
|
|
10
13
|
let sharpConfigured = false;
|
|
11
14
|
//Compresses a base64 image intelligently to keep it under 1 MB if needed.
|
|
12
15
|
export async function maybeCompressBase64(base64) {
|
|
@@ -25,11 +28,19 @@ export async function maybeCompressBase64(base64) {
|
|
|
25
28
|
sharp.concurrency(1);
|
|
26
29
|
sharpConfigured = true;
|
|
27
30
|
}
|
|
28
|
-
const
|
|
31
|
+
const image = globalConfig.REMOTE_MCP
|
|
32
|
+
? sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS }).resize({
|
|
33
|
+
width: MAX_IMAGE_DIMENSION,
|
|
34
|
+
height: MAX_IMAGE_DIMENSION,
|
|
35
|
+
fit: "inside",
|
|
36
|
+
withoutEnlargement: true,
|
|
37
|
+
})
|
|
38
|
+
: sharp(buffer);
|
|
39
|
+
const compressedBuffer = await image.png({ quality }).toBuffer();
|
|
29
40
|
return compressedBuffer.toString("base64");
|
|
30
41
|
}
|
|
31
42
|
catch (err) {
|
|
32
|
-
logger.warn("Image compression
|
|
43
|
+
logger.warn("Image compression failed; returning the uncompressed image. %s", err instanceof Error ? err.message : String(err));
|
|
33
44
|
return base64;
|
|
34
45
|
}
|
|
35
46
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { SessionType } from "../../lib/constants.js";
|
|
2
2
|
import { BrowserStackConfig } from "../../lib/types.js";
|
|
3
|
-
export declare
|
|
3
|
+
export declare const MAX_SCREENSHOT_BYTES: number | undefined;
|
|
4
|
+
export declare function parseScreenshotUrls(text: string): string[];
|
|
5
|
+
export type Screenshot = {
|
|
4
6
|
url: string;
|
|
5
7
|
base64: string;
|
|
6
|
-
|
|
8
|
+
mimeType: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function fetchAutomationScreenshots(sessionId: string, sessionType: SessionType | undefined, config: BrowserStackConfig): Promise<Screenshot[]>;
|
|
@@ -3,6 +3,10 @@ import { SessionType } from "../../lib/constants.js";
|
|
|
3
3
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
4
4
|
import { apiClient } from "../../lib/apiClient.js";
|
|
5
5
|
import globalConfig from "../../config.js";
|
|
6
|
+
import { MAX_LOG_BYTES } from "../failurelogs-utils/utils.js";
|
|
7
|
+
export const MAX_SCREENSHOT_BYTES = globalConfig.REMOTE_MCP
|
|
8
|
+
? 10 * 1024 * 1024
|
|
9
|
+
: undefined;
|
|
6
10
|
async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
7
11
|
const authString = getBrowserStackAuth(config);
|
|
8
12
|
const auth = Buffer.from(authString).toString("base64");
|
|
@@ -14,47 +18,112 @@ async function extractScreenshotUrls(sessionId, sessionType, config) {
|
|
|
14
18
|
"Content-Type": "application/json",
|
|
15
19
|
Authorization: `Basic ${auth}`,
|
|
16
20
|
},
|
|
21
|
+
maxContentLength: MAX_LOG_BYTES,
|
|
17
22
|
raise_error: false,
|
|
18
23
|
});
|
|
24
|
+
if (response.status === 413) {
|
|
25
|
+
throw new Error("The logs for this session are too large (over 5 MB) to find screenshots here. Open the session in the BrowserStack dashboard to view them.");
|
|
26
|
+
}
|
|
19
27
|
await assertOkResponse(response, "Session");
|
|
20
28
|
const text = typeof response.data === "string"
|
|
21
29
|
? response.data
|
|
22
30
|
: JSON.stringify(response.data);
|
|
31
|
+
return parseScreenshotUrls(text);
|
|
32
|
+
}
|
|
33
|
+
const LOG_PREFIX = String.raw `^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}:\d{1,2}:\d{1,3} `;
|
|
34
|
+
const LINE_TYPE_PATTERN = new RegExp(`${LOG_PREFIX}([A-Z_]+) `);
|
|
35
|
+
const SCREENSHOT_REQUEST_PATTERN = new RegExp(String.raw `${LOG_PREFIX}REQUEST \[[^\]]+\] GET /session/[^/\s]+/screenshot(\s|$)`);
|
|
36
|
+
const RESPONSE_VALUE_PATTERN = new RegExp(String.raw `${LOG_PREFIX}RESPONSE \{"value"\s*:\s*"(https://[^"]+)"`);
|
|
37
|
+
const DEBUG_SCREENSHOT_PATTERN = new RegExp(String.raw `${LOG_PREFIX}DEBUG (https://\S+)$`);
|
|
38
|
+
const TRUSTED_HOST_PATTERN = /(^|\.)browserstack\.com$|\.s3([.-][a-z0-9-]+)*\.amazonaws\.com$|\.backblazeb2\.com$/;
|
|
39
|
+
function isTrustedScreenshotUrl(value) {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(value);
|
|
42
|
+
return url.protocol === "https:" && TRUSTED_HOST_PATTERN.test(url.hostname);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function parseScreenshotUrls(text) {
|
|
23
49
|
const urls = [];
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
const lines = text.split("\n").map((line) => line.trimEnd());
|
|
51
|
+
for (let i = 0; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
const debugMatch = line.match(DEBUG_SCREENSHOT_PATTERN);
|
|
54
|
+
if (debugMatch) {
|
|
55
|
+
urls.push(debugMatch[1]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!SCREENSHOT_REQUEST_PATTERN.test(line)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
62
|
+
const lineType = lines[j].match(LINE_TYPE_PATTERN)?.[1];
|
|
63
|
+
if (lineType === "REQUEST") {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (lineType === "RESPONSE") {
|
|
67
|
+
const valueMatch = lines[j].match(RESPONSE_VALUE_PATTERN);
|
|
68
|
+
if (valueMatch) {
|
|
69
|
+
urls.push(valueMatch[1]);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
35
72
|
}
|
|
36
73
|
}
|
|
37
74
|
}
|
|
38
|
-
return urls;
|
|
75
|
+
return [...new Set(urls)].filter(isTrustedScreenshotUrl);
|
|
76
|
+
}
|
|
77
|
+
function detectImageMimeType(buffer) {
|
|
78
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
|
79
|
+
return "image/jpeg";
|
|
80
|
+
}
|
|
81
|
+
return "image/png";
|
|
39
82
|
}
|
|
83
|
+
// Returns null for a screenshot over MAX_SCREENSHOT_BYTES so the rest still load.
|
|
40
84
|
async function fetchAndCompress(url) {
|
|
41
|
-
const response = await apiClient.get({
|
|
85
|
+
const response = await apiClient.get({
|
|
86
|
+
url,
|
|
87
|
+
responseType: "arraybuffer",
|
|
88
|
+
maxContentLength: MAX_SCREENSHOT_BYTES,
|
|
89
|
+
raise_error: false,
|
|
90
|
+
});
|
|
91
|
+
if (response.status === 413) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new Error(`Failed to download screenshot: ${response.status} ${response.statusText}`);
|
|
96
|
+
}
|
|
42
97
|
// Axios returns response.data as a Buffer for binary data
|
|
43
|
-
const
|
|
44
|
-
|
|
98
|
+
const buffer = Buffer.from(response.data);
|
|
99
|
+
const base64 = await maybeCompressBase64(buffer.toString("base64"));
|
|
100
|
+
// Detect AFTER compression: sharp re-encodes anything over 1 MB to PNG, so
|
|
101
|
+
// reading the original bytes would label a large JPEG with the wrong type.
|
|
102
|
+
return {
|
|
103
|
+
url,
|
|
104
|
+
base64,
|
|
105
|
+
mimeType: detectImageMimeType(Buffer.from(base64, "base64")),
|
|
106
|
+
};
|
|
45
107
|
}
|
|
46
108
|
//Converts screenshot URLs to base64 encoded images
|
|
47
109
|
async function convertUrlsToBase64(urls) {
|
|
110
|
+
let results;
|
|
48
111
|
// Hosted pods are memory-constrained: process one image at a time. Local runs
|
|
49
112
|
// keep the concurrent path.
|
|
50
113
|
if (globalConfig.REMOTE_MCP) {
|
|
51
|
-
|
|
114
|
+
results = [];
|
|
52
115
|
for (const url of urls) {
|
|
53
|
-
|
|
116
|
+
results.push(await fetchAndCompress(url));
|
|
54
117
|
}
|
|
55
|
-
return screenshots;
|
|
56
118
|
}
|
|
57
|
-
|
|
119
|
+
else {
|
|
120
|
+
results = await Promise.all(urls.map(fetchAndCompress));
|
|
121
|
+
}
|
|
122
|
+
const screenshots = results.filter((s) => s !== null);
|
|
123
|
+
if (urls.length > 0 && screenshots.length === 0) {
|
|
124
|
+
throw new Error("The screenshots in this session are too large (over 10 MB each) to fetch here. Open the session in the BrowserStack dashboard to view them.");
|
|
125
|
+
}
|
|
126
|
+
return screenshots;
|
|
58
127
|
}
|
|
59
128
|
//Fetches and converts screenshot URLs to base64 encoded images
|
|
60
129
|
export async function fetchAutomationScreenshots(sessionId, sessionType = SessionType.Automate, config) {
|
package/dist/tools/automate.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
|
|
|
14
14
|
content: [
|
|
15
15
|
{
|
|
16
16
|
type: "text",
|
|
17
|
-
text: "No screenshots found in the session
|
|
17
|
+
text: "No screenshots found in the session logs. Run with browserstack.debug: true to capture visual-log screenshots.",
|
|
18
18
|
},
|
|
19
19
|
],
|
|
20
20
|
isError: true,
|
|
@@ -23,7 +23,7 @@ export async function fetchAutomationScreenshotsTool(args, config) {
|
|
|
23
23
|
const results = screenshots.map((screenshot, index) => ({
|
|
24
24
|
type: "image",
|
|
25
25
|
data: screenshot.base64,
|
|
26
|
-
mimeType:
|
|
26
|
+
mimeType: screenshot.mimeType,
|
|
27
27
|
_meta: { url: screenshot.url, index: index + 1 },
|
|
28
28
|
}));
|
|
29
29
|
return {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrowing a response the product will not narrow itself.
|
|
3
|
+
*
|
|
4
|
+
* invokeCapability makes one request and hands back what came off the wire. That is the
|
|
5
|
+
* right default and it stays the default: a caller reasoning about a product's answer
|
|
6
|
+
* should be reasoning about the product's answer. This is the declared exception.
|
|
7
|
+
*
|
|
8
|
+
* It exists because of `list_browsers`. `/automate/browsers.json` returns every operating
|
|
9
|
+
* system, browser and version combination the account can run — 5,251 rows and 604 KB —
|
|
10
|
+
* and accepts no parameters at all: `?os=Windows`, `?browser=chrome` and `?zzz=1` all come
|
|
11
|
+
* back byte-identical to the bare call. Its guidance told the caller to "narrow it
|
|
12
|
+
* yourself", which it had no means to do; the only way to use the capability was to not be
|
|
13
|
+
* an agent, because 604 KB of JSON is most of a context window spent to learn that Chrome
|
|
14
|
+
* runs on Windows 11.
|
|
15
|
+
*
|
|
16
|
+
* WHAT KEEPS THIS FROM BECOMING A SECOND API. Three rules, and they are the whole design:
|
|
17
|
+
*
|
|
18
|
+
* 1. A capability must declare `narrowable` before any of this is reachable. Nothing is
|
|
19
|
+
* applied to a capability that did not ask for it, so the untouched-response promise
|
|
20
|
+
* is intact everywhere it was intact before.
|
|
21
|
+
* 2. Only the fields it names may be used, and a field outside that set is refused BY
|
|
22
|
+
* NAME with the set listed — the same way an unknown host is refused. A caller that
|
|
23
|
+
* guesses `platform` learns what the fields are instead of silently getting everything.
|
|
24
|
+
* 3. The result says it was narrowed, from how many rows, and by what. A smaller answer
|
|
25
|
+
* that does not announce itself is indistinguishable from a product that returned less
|
|
26
|
+
* than it has, which is the failure this must not introduce.
|
|
27
|
+
*
|
|
28
|
+
* It is NOT a query language and should not grow into one. Equality, membership, distinct
|
|
29
|
+
* tuples, projection, a cap. Anything a caller cannot express here is a reason to look for
|
|
30
|
+
* a product endpoint that filters, not a reason to add an operator.
|
|
31
|
+
*/
|
|
32
|
+
import { Capability } from "./types.js";
|
|
33
|
+
/** What the caller asked for. Every field is optional; an empty selection narrows nothing. */
|
|
34
|
+
export interface Selection {
|
|
35
|
+
/**
|
|
36
|
+
* Field → the value it must take, or any of several.
|
|
37
|
+
*
|
|
38
|
+
* Strings compare case-insensitively. The product's own spellings are not consistent —
|
|
39
|
+
* `Windows` and `OS X` are capitalised, `android` and `ios` are not, in the same column
|
|
40
|
+
* of the same response — so an exact match would refuse half the corpus on a technicality
|
|
41
|
+
* the caller cannot see from the field name.
|
|
42
|
+
*/
|
|
43
|
+
where?: Record<string, unknown>;
|
|
44
|
+
/** Return one row per distinct combination of these fields, and nothing else. */
|
|
45
|
+
distinct?: string[];
|
|
46
|
+
/** Keep only these fields on each row. */
|
|
47
|
+
fields?: string[];
|
|
48
|
+
/** At most this many rows, applied last. */
|
|
49
|
+
limit?: number;
|
|
50
|
+
}
|
|
51
|
+
export interface Narrowing {
|
|
52
|
+
/** Rows the product sent. */
|
|
53
|
+
from: number;
|
|
54
|
+
/** Rows being handed back. */
|
|
55
|
+
to: number;
|
|
56
|
+
/** Echoed so the answer carries the question — a narrowed body is not self-describing. */
|
|
57
|
+
by: Selection;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Apply a selection to a response, or refuse it.
|
|
61
|
+
*
|
|
62
|
+
* Returns `undefined` when there is nothing to do — no selection, or a capability that
|
|
63
|
+
* declares none — so the caller hands back the original body and the default holds.
|
|
64
|
+
*/
|
|
65
|
+
export declare function narrowResponse(capability: Capability, body: unknown, selection: Selection | undefined): {
|
|
66
|
+
body: unknown;
|
|
67
|
+
narrowing: Narrowing;
|
|
68
|
+
} | undefined;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrowing a response the product will not narrow itself.
|
|
3
|
+
*
|
|
4
|
+
* invokeCapability makes one request and hands back what came off the wire. That is the
|
|
5
|
+
* right default and it stays the default: a caller reasoning about a product's answer
|
|
6
|
+
* should be reasoning about the product's answer. This is the declared exception.
|
|
7
|
+
*
|
|
8
|
+
* It exists because of `list_browsers`. `/automate/browsers.json` returns every operating
|
|
9
|
+
* system, browser and version combination the account can run — 5,251 rows and 604 KB —
|
|
10
|
+
* and accepts no parameters at all: `?os=Windows`, `?browser=chrome` and `?zzz=1` all come
|
|
11
|
+
* back byte-identical to the bare call. Its guidance told the caller to "narrow it
|
|
12
|
+
* yourself", which it had no means to do; the only way to use the capability was to not be
|
|
13
|
+
* an agent, because 604 KB of JSON is most of a context window spent to learn that Chrome
|
|
14
|
+
* runs on Windows 11.
|
|
15
|
+
*
|
|
16
|
+
* WHAT KEEPS THIS FROM BECOMING A SECOND API. Three rules, and they are the whole design:
|
|
17
|
+
*
|
|
18
|
+
* 1. A capability must declare `narrowable` before any of this is reachable. Nothing is
|
|
19
|
+
* applied to a capability that did not ask for it, so the untouched-response promise
|
|
20
|
+
* is intact everywhere it was intact before.
|
|
21
|
+
* 2. Only the fields it names may be used, and a field outside that set is refused BY
|
|
22
|
+
* NAME with the set listed — the same way an unknown host is refused. A caller that
|
|
23
|
+
* guesses `platform` learns what the fields are instead of silently getting everything.
|
|
24
|
+
* 3. The result says it was narrowed, from how many rows, and by what. A smaller answer
|
|
25
|
+
* that does not announce itself is indistinguishable from a product that returned less
|
|
26
|
+
* than it has, which is the failure this must not introduce.
|
|
27
|
+
*
|
|
28
|
+
* It is NOT a query language and should not grow into one. Equality, membership, distinct
|
|
29
|
+
* tuples, projection, a cap. Anything a caller cannot express here is a reason to look for
|
|
30
|
+
* a product endpoint that filters, not a reason to add an operator.
|
|
31
|
+
*/
|
|
32
|
+
import { InvocationError } from "./index-loader.js";
|
|
33
|
+
function isSelectionEmpty(selection) {
|
|
34
|
+
return (!selection.where &&
|
|
35
|
+
!selection.distinct &&
|
|
36
|
+
!selection.fields &&
|
|
37
|
+
selection.limit === undefined);
|
|
38
|
+
}
|
|
39
|
+
/** The array inside a body, by the capability's declared path. */
|
|
40
|
+
function rowsAt(body, path) {
|
|
41
|
+
let here = body;
|
|
42
|
+
for (const step of (path ?? "").split(".").filter(Boolean)) {
|
|
43
|
+
if (typeof here !== "object" || here === null)
|
|
44
|
+
return undefined;
|
|
45
|
+
here = here[step];
|
|
46
|
+
}
|
|
47
|
+
return Array.isArray(here) ? here : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The body with its array replaced, leaving every sibling key as the product sent it. */
|
|
50
|
+
function withRows(body, path, rows) {
|
|
51
|
+
const steps = (path ?? "").split(".").filter(Boolean);
|
|
52
|
+
if (steps.length === 0)
|
|
53
|
+
return rows;
|
|
54
|
+
const clone = {
|
|
55
|
+
...body,
|
|
56
|
+
};
|
|
57
|
+
let here = clone;
|
|
58
|
+
for (const step of steps.slice(0, -1)) {
|
|
59
|
+
here[step] = { ...here[step] };
|
|
60
|
+
here = here[step];
|
|
61
|
+
}
|
|
62
|
+
here[steps[steps.length - 1]] = rows;
|
|
63
|
+
return clone;
|
|
64
|
+
}
|
|
65
|
+
function sameValue(cell, wanted) {
|
|
66
|
+
if (typeof cell === "string" && typeof wanted === "string")
|
|
67
|
+
return cell.toLowerCase() === wanted.toLowerCase();
|
|
68
|
+
// `real_mobile` is true or null, and a caller who writes "true" means true.
|
|
69
|
+
if (typeof cell === "boolean" && typeof wanted === "string")
|
|
70
|
+
return String(cell) === wanted.toLowerCase();
|
|
71
|
+
if (cell === null || cell === undefined)
|
|
72
|
+
return wanted === null || wanted === "null";
|
|
73
|
+
return cell === wanted;
|
|
74
|
+
}
|
|
75
|
+
function assertKnownFields(used, allowed, what) {
|
|
76
|
+
const unknown = used.filter((field) => !allowed.includes(field));
|
|
77
|
+
if (unknown.length > 0)
|
|
78
|
+
throw new InvocationError(`${what} names ${unknown.map((f) => `'${f}'`).join(", ")}, which this capability ` +
|
|
79
|
+
`cannot be narrowed on. The fields it accepts are: ${allowed.join(", ")}.`);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Apply a selection to a response, or refuse it.
|
|
83
|
+
*
|
|
84
|
+
* Returns `undefined` when there is nothing to do — no selection, or a capability that
|
|
85
|
+
* declares none — so the caller hands back the original body and the default holds.
|
|
86
|
+
*/
|
|
87
|
+
export function narrowResponse(capability, body, selection) {
|
|
88
|
+
if (!selection || isSelectionEmpty(selection))
|
|
89
|
+
return undefined;
|
|
90
|
+
const declared = capability.narrowable;
|
|
91
|
+
if (!declared)
|
|
92
|
+
throw new InvocationError("that capability does not support `select`: it returns the product's response as " +
|
|
93
|
+
"sent. describeCapability names the ones that do, under `narrowable`.");
|
|
94
|
+
const allowed = declared.fields;
|
|
95
|
+
assertKnownFields(Object.keys(selection.where ?? {}), allowed, "`where`");
|
|
96
|
+
assertKnownFields(selection.distinct ?? [], allowed, "`distinct`");
|
|
97
|
+
assertKnownFields(selection.fields ?? [], allowed, "`fields`");
|
|
98
|
+
const rows = rowsAt(body, declared.path);
|
|
99
|
+
// A body that is not the shape the capability promised is handed back whole. Narrowing
|
|
100
|
+
// an error payload, or a response that changed shape, would hide exactly the thing the
|
|
101
|
+
// caller needs to see.
|
|
102
|
+
if (!rows)
|
|
103
|
+
return undefined;
|
|
104
|
+
let out = rows;
|
|
105
|
+
for (const [field, wanted] of Object.entries(selection.where ?? {})) {
|
|
106
|
+
const wantedValues = Array.isArray(wanted) ? wanted : [wanted];
|
|
107
|
+
out = out.filter((row) => {
|
|
108
|
+
const cell = row?.[field];
|
|
109
|
+
return wantedValues.some((value) => sameValue(cell, value));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (selection.distinct?.length) {
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
const unique = [];
|
|
115
|
+
for (const row of out) {
|
|
116
|
+
const picked = Object.fromEntries(selection.distinct.map((field) => [
|
|
117
|
+
field,
|
|
118
|
+
row?.[field],
|
|
119
|
+
]));
|
|
120
|
+
const key = JSON.stringify(picked);
|
|
121
|
+
if (seen.has(key))
|
|
122
|
+
continue;
|
|
123
|
+
seen.add(key);
|
|
124
|
+
unique.push(picked);
|
|
125
|
+
}
|
|
126
|
+
out = unique;
|
|
127
|
+
}
|
|
128
|
+
else if (selection.fields?.length) {
|
|
129
|
+
out = out.map((row) => Object.fromEntries(selection.fields.map((field) => [
|
|
130
|
+
field,
|
|
131
|
+
row?.[field],
|
|
132
|
+
])));
|
|
133
|
+
}
|
|
134
|
+
if (selection.limit !== undefined)
|
|
135
|
+
out = out.slice(0, Math.max(0, selection.limit));
|
|
136
|
+
return {
|
|
137
|
+
body: withRows(body, declared.path, out),
|
|
138
|
+
narrowing: { from: rows.length, to: out.length, by: selection },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -21,6 +21,7 @@ import { indexPaths, isEnabled, resolveBaseUrl } from "./config.js";
|
|
|
21
21
|
import { redact } from "./redact.js";
|
|
22
22
|
import { fetchTransport } from "./egress.js";
|
|
23
23
|
import { CapabilityRegistry, InvocationError, resolveResponses, } from "./index-loader.js";
|
|
24
|
+
import { narrowResponse } from "./narrow.js";
|
|
24
25
|
import { invoke } from "./resolve.js";
|
|
25
26
|
import { ambiguousProducts, productAnchors, isBrowseQuery, searchCapabilities, singular, terms, vocabularyOf, } from "./search.js";
|
|
26
27
|
export const PERMISSION_VALUES = ["not_asked", "granted", "denied"];
|
|
@@ -793,8 +794,27 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
793
794
|
// Dropped rather than overwritten: the raw field holds `{"$response": …}` pointers,
|
|
794
795
|
// and spreading the capability would leak them through whenever the resolved value
|
|
795
796
|
// is absent.
|
|
796
|
-
|
|
797
|
+
//
|
|
798
|
+
// THE WIRE FIELDS GO WITH IT, for the same reason the route does: they describe how
|
|
799
|
+
// WE reach the product, not anything the caller supplies or acts on. `host` names
|
|
800
|
+
// which of the product's origins serves this one capability; `encoding` and
|
|
801
|
+
// `send_empty_body` say how the request body is framed; `local_only` and `disabled`
|
|
802
|
+
// are claims about which surface publishes it, already answered by the capability
|
|
803
|
+
// being present at all. A contract that shows them invites a caller to reason about
|
|
804
|
+
// plumbing it cannot influence — and `host: "api"` was doing exactly that in a
|
|
805
|
+
// shipped describe response.
|
|
806
|
+
//
|
|
807
|
+
// `invocable` STAYS, and is the one of these that is not plumbing. Presence says the
|
|
808
|
+
// capability is published, which is not the same as callable: a reference-only entry
|
|
809
|
+
// is published precisely so search can still find it and say what to do instead.
|
|
810
|
+
// Stripping the flag would leave that fact only in a prose guidance line.
|
|
811
|
+
const { responses: unresolved, method, path, host, encoding, send_empty_body, local_only, disabled, ...contract } = capability;
|
|
797
812
|
void unresolved;
|
|
813
|
+
void host;
|
|
814
|
+
void encoding;
|
|
815
|
+
void send_empty_body;
|
|
816
|
+
void local_only;
|
|
817
|
+
void disabled;
|
|
798
818
|
return ok({
|
|
799
819
|
// No build_id here either, same reason. `product` stays: describeCapability
|
|
800
820
|
// resolves by NAME and its product argument is optional, so the answer has to
|
|
@@ -826,7 +846,9 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
826
846
|
"as given — that is the handle. Only when a result carries no `name` (some products " +
|
|
827
847
|
"do not publish them yet) pass `method` and `path` instead, exactly as returned. " +
|
|
828
848
|
"Arguments go in path_params / query / body under the spec's own names. One call " +
|
|
829
|
-
"makes exactly one request and returns the product's own response
|
|
849
|
+
"makes exactly one request and returns the product's own response as sent — unless " +
|
|
850
|
+
"you pass `select`, which only capabilities whose contract carries `narrowable` " +
|
|
851
|
+
"accept, and which reports what it narrowed. When " +
|
|
830
852
|
"`completed` is false there is another page, which you fetch by sending the " +
|
|
831
853
|
"capability's own page parameter. If the mode is 'write' you MUST ask the user " +
|
|
832
854
|
"first, then resend with user_permission='granted' and a change_summary; both are " +
|
|
@@ -870,6 +892,29 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
870
892
|
.string()
|
|
871
893
|
.optional()
|
|
872
894
|
.describe("What will change. Required for writes."),
|
|
895
|
+
select: z
|
|
896
|
+
.object({
|
|
897
|
+
where: z
|
|
898
|
+
.record(z.string(), z.any())
|
|
899
|
+
.optional()
|
|
900
|
+
.describe("Field -> the value it must take, or an array of accepted values. Strings " +
|
|
901
|
+
"match case-insensitively."),
|
|
902
|
+
distinct: z
|
|
903
|
+
.array(z.string())
|
|
904
|
+
.optional()
|
|
905
|
+
.describe("Return one row per distinct combination of these fields and nothing else — " +
|
|
906
|
+
"the cheapest way to ask what values exist."),
|
|
907
|
+
fields: z
|
|
908
|
+
.array(z.string())
|
|
909
|
+
.optional()
|
|
910
|
+
.describe("Keep only these fields on each row."),
|
|
911
|
+
limit: z.number().optional().describe("At most this many rows."),
|
|
912
|
+
})
|
|
913
|
+
.optional()
|
|
914
|
+
.describe("ONLY for a capability whose contract carries `narrowable`, which names the " +
|
|
915
|
+
"fields this may use; on any other capability it is refused. Those endpoints " +
|
|
916
|
+
"take no filtering parameters of their own, so this is applied to the response " +
|
|
917
|
+
"here, and the result reports what it narrowed and from how many rows."),
|
|
873
918
|
}, {
|
|
874
919
|
title: "Invoke Capability",
|
|
875
920
|
// Not read-only: this is the one tool that writes. Never destructive, because
|
|
@@ -949,6 +994,20 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
949
994
|
}
|
|
950
995
|
}
|
|
951
996
|
const result = await invoke(capability, args, await deps.baseUrlFor(product, capability.host), deps.credentialsFor(), transport, registry.index.products[product]?.auth, product);
|
|
997
|
+
// NARROWING IS APPLIED TO A SUCCESS ONLY. A 4xx body is the diagnosis the caller
|
|
998
|
+
// needs whole, and a selection that happened to match none of its keys would
|
|
999
|
+
// quietly empty it. `narrowResponse` refuses an unsupported `select` by throwing,
|
|
1000
|
+
// which lands in the InvocationError branch below with the accepted fields listed.
|
|
1001
|
+
const narrowed = result.ok
|
|
1002
|
+
? narrowResponse(capability, result.http_response.body, input.select)
|
|
1003
|
+
: undefined;
|
|
1004
|
+
const answer = narrowed
|
|
1005
|
+
? {
|
|
1006
|
+
...result,
|
|
1007
|
+
http_response: { ...result.http_response, body: narrowed.body },
|
|
1008
|
+
narrowed: narrowed.narrowing,
|
|
1009
|
+
}
|
|
1010
|
+
: result;
|
|
952
1011
|
// The one row for a completed invoke. invoke() returns a 4xx/5xx rather than
|
|
953
1012
|
// throwing, so `success` keeps its tool-level meaning and these two fields carry
|
|
954
1013
|
// whether the product call worked. Status 0 means it could not be reached.
|
|
@@ -957,8 +1016,14 @@ export function addCapabilityRegistryTools(server, deps, config) {
|
|
|
957
1016
|
upstream_ok: result.ok,
|
|
958
1017
|
upstream_status: result.http_response.status,
|
|
959
1018
|
change_summary: redact(input.change_summary),
|
|
1019
|
+
...(narrowed
|
|
1020
|
+
? {
|
|
1021
|
+
narrowed_from: narrowed.narrowing.from,
|
|
1022
|
+
narrowed_to: narrowed.narrowing.to,
|
|
1023
|
+
}
|
|
1024
|
+
: {}),
|
|
960
1025
|
});
|
|
961
|
-
return ok(
|
|
1026
|
+
return ok(answer);
|
|
962
1027
|
}
|
|
963
1028
|
catch (error) {
|
|
964
1029
|
if (error instanceof InvocationError)
|
|
@@ -117,24 +117,6 @@ export interface ProductAmbiguity {
|
|
|
117
117
|
/** No word in the request belongs to ANY product: there is nothing to route on. */
|
|
118
118
|
unknown?: boolean;
|
|
119
119
|
}
|
|
120
|
-
/**
|
|
121
|
-
* Products whose OWN vocabulary the query hits, when no word in the query settles it.
|
|
122
|
-
*
|
|
123
|
-
* Requiring `product` made every CALL unambiguous and did nothing about the agent making
|
|
124
|
-
* two of them: "list all projects" was answered by searching tm, then Load Testing, then
|
|
125
|
-
* merging — a question about which product the user meant, answered by guessing both.
|
|
126
|
-
* Tool-description prose asking the agent to check with the user is a suggestion it is
|
|
127
|
-
* free to decline, and declining is cheaper than interrupting someone.
|
|
128
|
-
*
|
|
129
|
-
* WHOLE VOCABULARY ENTRIES, never their constituent words. Splitting them makes almost
|
|
130
|
-
* everything look shared: Load Testing's "load test" and tm's "test case" both yield
|
|
131
|
-
* `test`, which flagged 135 of the 198 eval queries. Matching entries whole flags 27.
|
|
132
|
-
*
|
|
133
|
-
* A UNIQUE TERM SETTLES IT. "create a test case in a folder" contains `folder`, which
|
|
134
|
-
* both products claim, and `test case`, which only tm does — so the query has already
|
|
135
|
-
* answered the question and there is nothing to ask. Only queries whose every recognised
|
|
136
|
-
* word is shared are genuinely undecided.
|
|
137
|
-
*/
|
|
138
120
|
export declare function ambiguousProducts(products: Record<string, ProductIndex>, query: string | undefined): ProductAmbiguity;
|
|
139
121
|
/** One entity's caller-facing vocabulary: what it is called, and what else it is called. */
|
|
140
122
|
export interface VocabularyEntry {
|
|
@@ -122,11 +122,32 @@ export function isBrowseQuery(query) {
|
|
|
122
122
|
const q = (query || "").trim().toLowerCase();
|
|
123
123
|
return q === "*" || q === "all";
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* OUR OWN NAME, WHICH IS NOT ROUTING VOCABULARY.
|
|
127
|
+
*
|
|
128
|
+
* Every product here is BrowserStack's, so the word distinguishes none of them — it is a
|
|
129
|
+
* stopword in the strict sense. It could not be spelled as one, because the camelCase
|
|
130
|
+
* split above runs first and turns "BrowserStack" into `browser` + `stack`, two words that
|
|
131
|
+
* ARE vocabulary: `browser` is a devicecontrol entity and an alias on a tm configuration.
|
|
132
|
+
*
|
|
133
|
+
* So the company's name claimed a product's noun. "Which Android apps have I uploaded to
|
|
134
|
+
* BrowserStack?" clashed over `browser` across four products, and the question put to the
|
|
135
|
+
* user — devicecontrol or mobiledevicecontrol or tm or tra — was unanswerable, because no
|
|
136
|
+
* word in the request was really about browsers at all. It reached further than queries
|
|
137
|
+
* that name us: the tm and tra summaries say "BrowserStack", so those two became claimants
|
|
138
|
+
* of `browser` permanently, and "what browsers can I use" clashed four ways on a word only
|
|
139
|
+
* devicecontrol models.
|
|
140
|
+
*
|
|
141
|
+
* Matched after the split and the fold, so one pattern covers "BrowserStack", "browserstack"
|
|
142
|
+
* and "browser stack". `bstack` is the same name in the shorthand the logs and repos use.
|
|
143
|
+
*/
|
|
144
|
+
const BRAND = /\bbrowser\s*stack\b|\bbstack\b/g;
|
|
125
145
|
export function terms(text) {
|
|
126
146
|
return [
|
|
127
147
|
...(text || "")
|
|
128
148
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
129
149
|
.toLowerCase()
|
|
150
|
+
.replace(BRAND, " ")
|
|
130
151
|
.matchAll(WORD),
|
|
131
152
|
]
|
|
132
153
|
.map((match) => match[0])
|
|
@@ -530,6 +551,22 @@ const GENERIC_ANCHORS = new Set(["test", "tests", "testing"]);
|
|
|
530
551
|
* answered the question and there is nothing to ask. Only queries whose every recognised
|
|
531
552
|
* word is shared are genuinely undecided.
|
|
532
553
|
*/
|
|
554
|
+
/**
|
|
555
|
+
* The words that do disclaiming work in a product summary.
|
|
556
|
+
*
|
|
557
|
+
* Deliberately a list of markers rather than anything resembling negation parsing: the
|
|
558
|
+
* sentences this has to catch are written to one pattern ("X is not here", "neither is Y",
|
|
559
|
+
* "unlike Test Management", "rather than a browser"), and a looser rule would start
|
|
560
|
+
* discarding prose that claims.
|
|
561
|
+
*/
|
|
562
|
+
const DISCLAIMS = /\b(?:not|never|neither|nor|unlike|rather than|instead of|except)\b/i;
|
|
563
|
+
/** A summary with its disclaiming sentences removed, for the claimant pass only. */
|
|
564
|
+
function claimingSentences(summary) {
|
|
565
|
+
return (summary ?? "")
|
|
566
|
+
.split(/(?<=[.!?])\s+/)
|
|
567
|
+
.filter((sentence) => !DISCLAIMS.test(sentence))
|
|
568
|
+
.join(" ");
|
|
569
|
+
}
|
|
533
570
|
export function ambiguousProducts(products, query) {
|
|
534
571
|
const none = { products: [], terms: [] };
|
|
535
572
|
if (terms(query).length === 0)
|
|
@@ -580,9 +617,20 @@ export function ambiguousProducts(products, query) {
|
|
|
580
617
|
// product already models catches that, and refusing to mine NEW terms out of it is what
|
|
581
618
|
// keeps the prose from taking over: a summary is a paragraph of verbs and nouns written
|
|
582
619
|
// to sell the product, and every word in it would otherwise become routing vocabulary.
|
|
620
|
+
//
|
|
621
|
+
// AND ONLY FROM A SENTENCE THAT CLAIMS. These summaries route deliberately — each one
|
|
622
|
+
// carries a sentence saying what is NOT in it and where to go instead, which is the most
|
|
623
|
+
// useful sentence in the paragraph and was being read as the opposite of what it says.
|
|
624
|
+
// "Browsers are not here — those are device control" made mobiledevicecontrol a claimant
|
|
625
|
+
// of `browser`, and "Mobile APPS are not here" made devicecontrol a claimant of `app`.
|
|
626
|
+
// Both products were then offered to the user for a word their own summary disowns.
|
|
627
|
+
//
|
|
628
|
+
// Dropping a sentence can only ever narrow a clash, never misdirect one: prose cannot
|
|
629
|
+
// introduce a term, so the worst case is a clash that goes unreported — and a product
|
|
630
|
+
// that genuinely handles something does not say so in a negated sentence.
|
|
583
631
|
const summaries = Object.fromEntries(Object.entries(products).map(([product, index]) => [
|
|
584
632
|
product,
|
|
585
|
-
` ${fold(index.summary
|
|
633
|
+
` ${fold(claimingSentences(index.summary))} `,
|
|
586
634
|
]));
|
|
587
635
|
for (const [key, claimants] of owners) {
|
|
588
636
|
for (const [product, text] of Object.entries(summaries)) {
|
|
@@ -229,6 +229,32 @@ export interface Capability {
|
|
|
229
229
|
* something this can verify.
|
|
230
230
|
*/
|
|
231
231
|
send_empty_body?: boolean;
|
|
232
|
+
/**
|
|
233
|
+
* This capability's array response may be narrowed by the server, on these fields.
|
|
234
|
+
*
|
|
235
|
+
* THE PRODUCT CANNOT DO IT. Declaring this is a statement that the endpoint offers no
|
|
236
|
+
* filtering of its own — not that ours is more convenient. `/automate/browsers.json`
|
|
237
|
+
* returns 5,251 rows and ignores every query parameter it is given, so a caller asking
|
|
238
|
+
* which browsers run on Windows had to receive 604 KB and find out itself.
|
|
239
|
+
*
|
|
240
|
+
* Where the wire DOES filter, declare the parameters and let the product filter: a
|
|
241
|
+
* server-side pass still transfers the whole response and still costs the round trip,
|
|
242
|
+
* and it moves the contract off the product and onto us.
|
|
243
|
+
*
|
|
244
|
+
* Unset — which is every other capability — means `select` is refused outright and the
|
|
245
|
+
* response is handed back exactly as sent.
|
|
246
|
+
*/
|
|
247
|
+
narrowable?: {
|
|
248
|
+
/** The only fields `select` may name. Anything else is refused with this list. */
|
|
249
|
+
fields: string[];
|
|
250
|
+
/**
|
|
251
|
+
* Dotted path to the array inside the body; omitted when the body IS the array.
|
|
252
|
+
* Siblings of the array are preserved, so an envelope survives the narrowing.
|
|
253
|
+
*/
|
|
254
|
+
path?: string;
|
|
255
|
+
/** One line for the contract: why this is here rather than on the wire. */
|
|
256
|
+
note?: string;
|
|
257
|
+
};
|
|
232
258
|
paginated?: boolean;
|
|
233
259
|
/** The largest page the operation declares. */
|
|
234
260
|
max_page_size?: number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiClient } from "../../lib/apiClient.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { errorText } from "../../lib/error.js";
|
|
4
4
|
import { fetchFormFields, normalizeDefaultFields as normalizeDefaultFieldsFromForm, projectIdentifierToId, } from "./TCG-utils/api.js";
|
|
5
5
|
import { getTMBaseURL } from "../../lib/tm-base-url.js";
|
|
6
6
|
import { getBrowserStackAuth } from "../../lib/get-auth.js";
|
|
@@ -218,12 +218,14 @@ Error details: ${JSON.stringify(err.response?.data || err.message)}`,
|
|
|
218
218
|
isError: true,
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
|
-
const
|
|
221
|
+
const status = err.response?.status;
|
|
222
|
+
const detail = errorText(err, "unknown error");
|
|
223
|
+
const hint = status === 401 ? " Check the BrowserStack credentials in use." : "";
|
|
222
224
|
return {
|
|
223
225
|
content: [
|
|
224
226
|
{
|
|
225
227
|
type: "text",
|
|
226
|
-
text: `Failed to update test case
|
|
228
|
+
text: `Failed to update test case${status ? ` (HTTP ${status})` : ""}: ${detail}.${hint}`,
|
|
227
229
|
},
|
|
228
230
|
],
|
|
229
231
|
isError: true,
|